Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc3a177410 | ||
|
|
c0880c4dc2 | ||
|
|
b9104454f9 | ||
|
|
73a10fafc7 | ||
|
|
954f84ea19 | ||
|
|
9f3a8ee391 | ||
|
|
cad6194447 | ||
|
|
3fd1b47fe7 | ||
|
|
5b57422523 | ||
|
|
523d7846cd | ||
|
|
502cda34cb | ||
|
|
c996121e9d | ||
|
|
f863e852c9 | ||
|
|
58b337770d | ||
|
|
ff1886ac62 | ||
|
|
28fdbbd346 | ||
|
|
3ee80c98ab | ||
|
|
8e0ef84060 | ||
|
|
68c141849f | ||
|
|
a9ad306693 | ||
|
|
a3e9985c15 | ||
|
|
08200bc178 | ||
|
|
a9ff48e200 | ||
|
|
66aa204f67 | ||
|
|
a76910a0c0 | ||
|
|
dc86203d29 | ||
|
|
9efdd87a73 | ||
|
|
301cd2287f | ||
|
|
524e3405e5 | ||
|
|
6e6d1f3e47 | ||
|
|
eda55ceb9e | ||
|
|
e8c3448797 | ||
|
|
5c1ff4bb15 | ||
|
|
280aeb2f47 | ||
|
|
b767aac8e0 | ||
|
|
920199862e | ||
|
|
8dd2a42448 | ||
|
|
d1a9056a1c | ||
|
|
a3476a7bf2 | ||
|
|
1c3158e098 | ||
|
|
75957fc3c0 | ||
|
|
ea83746297 | ||
|
|
b60ee706d4 | ||
|
|
4a60deb4a8 | ||
|
|
fc10031d22 | ||
|
|
2985122856 | ||
|
|
20342f2172 | ||
|
|
7bb588ca1b | ||
|
|
a2c0c58677 | ||
|
|
c441d83ba7 | ||
|
|
e601e88e95 | ||
|
|
fb8e677cd8 | ||
|
|
5aca005cb7 | ||
|
|
9f9e9fc9d3 | ||
|
|
f737418bb3 | ||
|
|
5b235a6e80 | ||
|
|
de6741a335 | ||
|
|
dd8305d97d | ||
|
|
06dfce87f5 | ||
|
|
7db4bd215d | ||
|
|
1639269ad4 | ||
|
|
7397051771 | ||
|
|
95089762ef | ||
|
|
ce3b12f349 | ||
|
|
acb82992ee | ||
|
|
0a7669b48f | ||
|
|
f140099a75 | ||
|
|
f3a59ee63a | ||
|
|
33a97e6d13 | ||
|
|
e7dce9616c | ||
|
|
1ccb617a86 | ||
|
|
dd3ed68483 | ||
|
|
428b42181d | ||
|
|
59c0906c79 | ||
|
|
75a9a45c6f | ||
|
|
b4527d9091 | ||
|
|
a32b7af3fd | ||
|
|
a6295d6b98 | ||
|
|
afb94d925e | ||
|
|
26d81b09b5 | ||
|
|
72ad5ad138 | ||
|
|
54db38be08 | ||
|
|
4301da630d | ||
|
|
e75e793a4b |
@@ -44,7 +44,7 @@ base64-encoded. The Go bundle `cmd/forge` serves the commit, and the reads an ag
|
||||
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `gitea_commit` | a | one commit on a branch of its own, created from `base` (default the default branch) when absent, carrying either `files` (each `{path, content \| content_base64 \| delete: true}`) `patch` (a unified diff, as `git diff` writes it), or `patch_file` with `patch_sha256` (a patch too large to type, see below); answers the commit and its parent |
|
||||
| `gitea_commit` | a | one commit on a branch of its own, created from `base` (default the default branch) when absent, carrying either `files` (each `{path, content \| content_base64 \| delete: true \| copy_from}`; `copy_from` `{owner, repo, ref, path}` copies a file, or every file under a directory recursively to the same relative paths, of another forge repository, read by the module at one commit sha resolved from `ref` once and named per entry in the answer; files only, a symlink is refused; the copied files count against the 200 files, 4 MiB each and 16 MiB together, and a copy over them is refused naming the count and size, to be split by subdirectory) `patch` (a unified diff, as `git diff` writes it), or `patch_file` with `patch_sha256` (a patch too large to type, see below); answers the commit and its parent |
|
||||
| `gitea_commits` | r | a ref's history, newest first, optionally only commits touching a path: sha, author, date, subject |
|
||||
| `gitea_compare` | r | what head holds that base does not: its commits and the files they touch |
|
||||
| `gitea_pull_reviews` | r | a pull request's reviews — state, the commit reviewed and whether it is the head now, stale, dismissed — with their line comments |
|
||||
|
||||
+54
-1
@@ -3,6 +3,7 @@
|
||||
// gitea. Both this module's tools and its events entrypoint import it, and nothing outside gitea
|
||||
// does.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
|
||||
import { MAIN_BRANCH, mainRuleBody, protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
|
||||
@@ -21,6 +22,19 @@ function commitSegment(sha: unknown): string {
|
||||
if (!isCommit(v)) throw new Refused(`${JSON.stringify(sha)} is not a commit. Nothing was sent`);
|
||||
return v;
|
||||
}
|
||||
/** git's hash of a tree from its entries: sha1 of "tree <length>\0" and, for each entry in git's order (byte order of the
|
||||
* name, a tree compared as its name and a slash), "<mode> <name>\0" and the 20 raw bytes of its sha. git writes a
|
||||
* tree's mode 40000 where the forge says 040000. */
|
||||
export function gitTreeHash(entries: { path: string; mode: string; type: string; sha: string }[]): string {
|
||||
const ordered = entries
|
||||
.map((e) => ({ e, key: Buffer.from(e.path + (e.type === "tree" ? "/" : "")) }))
|
||||
.sort((a, b) => Buffer.compare(a.key, b.key));
|
||||
const body = Buffer.concat(ordered.map(({ e }) => {
|
||||
if (!/^[0-9a-f]{40}$/i.test(String(e.sha))) throw new Error(`the forge named no sha for ${e.path}`);
|
||||
return Buffer.concat([Buffer.from(`${String(e.mode).replace(/^0+/, "")} ${e.path}\0`), Buffer.from(e.sha, "hex")]);
|
||||
}));
|
||||
return createHash("sha1").update(Buffer.concat([Buffer.from(`tree ${body.length}\0`), body])).digest("hex");
|
||||
}
|
||||
export type { BranchProtection, ProtectionWanted } from "./protection.js";
|
||||
|
||||
/** A repository, trimmed to what the mesh cares about. */
|
||||
@@ -92,6 +106,8 @@ export interface OpenPull {
|
||||
repo: string;
|
||||
number: number;
|
||||
base: string;
|
||||
/** The pull request's head branch: what a delivery group is named by (novox/hq ADR 0249). */
|
||||
head?: string;
|
||||
head_sha: string;
|
||||
html_url?: string;
|
||||
/** The contexts the base branch's protection requires; none when it requires none, or cannot be read. */
|
||||
@@ -374,7 +390,7 @@ export class GiteaClient {
|
||||
const rule = rules.get(base);
|
||||
const checks = await this.headChecks(found.owner, found.name, pull.head_sha);
|
||||
const labels = found.labels ?? [];
|
||||
out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head_sha: pull.head_sha,
|
||||
out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head: pull.head, head_sha: pull.head_sha,
|
||||
html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [],
|
||||
statuses: Object.keys(checks), checks, labels: labels.map((l) => l.name) });
|
||||
}
|
||||
@@ -510,6 +526,43 @@ export class GiteaClient {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** **A commit's trees: its root, and the tree of every directory that holds a `module.json`** (novox/hq issue 1234,
|
||||
* ADR 0437 §4 and §5), from the forge's one recursive listing of the commit, read page by page. A directory is
|
||||
* spelled as the repository spells it, no slash at either end, the root as `""`.
|
||||
*
|
||||
* **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages
|
||||
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did
|
||||
* see, since a directory left out would be taken for one without a module. Reads only. */
|
||||
async commitTrees(owner: string, repo: string, sha: string): Promise<{ root: string; trees: Record<string, string> }> {
|
||||
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
|
||||
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
|
||||
const entries: any[] = [];
|
||||
for (let page = 1; ; page++) {
|
||||
if (page > 200) throw cut("more than 200 pages");
|
||||
const got = await this.request<any>(`${base}&page=${page}&per_page=1000`);
|
||||
if (!Array.isArray(got?.tree)) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} has no tree`);
|
||||
entries.push(...got.tree);
|
||||
if (got.truncated !== true) break;
|
||||
// Truncated with nothing more to ask for: the forge itself could not give the rest.
|
||||
if (got.tree.length === 0 || entries.length >= Number(got.total_count ?? 0)) throw cut("truncated");
|
||||
}
|
||||
// **The listing's top `sha` is the commit's, not the root tree's** (seen live, novox/mesh-delivery at 7f61817), so the
|
||||
// root tree is hashed from the root's own entries, as git does (gitTreeHash).
|
||||
const root = gitTreeHash(entries.filter((e) => typeof e?.path === "string" && !e.path.includes("/")));
|
||||
const trees: Record<string, string> = {};
|
||||
const dirOf = new Map<string, string>();
|
||||
for (const e of entries) if (e?.type === "tree" && typeof e.path === "string") dirOf.set(e.path, String(e.sha));
|
||||
for (const e of entries) {
|
||||
if (e?.type !== "blob" || typeof e.path !== "string") continue;
|
||||
if (e.path !== "module.json" && !e.path.endsWith("/module.json")) continue;
|
||||
const dir = e.path === "module.json" ? "" : e.path.slice(0, -"/module.json".length);
|
||||
const tree = dir === "" ? root : dirOf.get(dir);
|
||||
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
|
||||
trees[dir] = tree;
|
||||
}
|
||||
return { root, trees };
|
||||
}
|
||||
|
||||
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
|
||||
async holdsFile(owner: string, repo: string, sha: string, file: string): Promise<boolean> {
|
||||
return this.exists(`${repoPath(owner, repo)}/contents/${encodePath(file)}?ref=${encodeURIComponent(sha)}`);
|
||||
|
||||
@@ -25,6 +25,8 @@ type FileChange struct {
|
||||
Content *string
|
||||
ContentBase64 *string
|
||||
Delete bool
|
||||
CopyFrom *CopyFrom // copy_from (novox/hq issue 1365): the content of a file or directory of another repository
|
||||
size int // the content's size in bytes, as committed
|
||||
}
|
||||
|
||||
// CommitAsk is one gitea_commit call.
|
||||
@@ -51,6 +53,7 @@ type Committed struct {
|
||||
Parent string `json:"parent"`
|
||||
Commit string `json:"commit"`
|
||||
Files []CommittedFile `json:"files,omitempty"`
|
||||
Copies []CopiedFrom `json:"copies,omitempty"`
|
||||
Said []string `json:"said"`
|
||||
}
|
||||
|
||||
@@ -118,6 +121,16 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) {
|
||||
}
|
||||
fc.Delete = b
|
||||
}
|
||||
if v, ok := m["copy_from"]; ok && v != nil {
|
||||
c, err := readCopyFrom(i, v)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
fc.CopyFrom = c
|
||||
if fc.Content != nil || fc.ContentBase64 != nil || fc.Delete {
|
||||
return a, refuse("files[%d] (%s) gives copy_from together with content, content_base64 or delete: it takes exactly one of them. Nothing was sent", i, fc.Path)
|
||||
}
|
||||
}
|
||||
a.Files = append(a.Files, fc)
|
||||
}
|
||||
}
|
||||
@@ -157,13 +170,13 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) {
|
||||
}
|
||||
seen[fc.Path] = true
|
||||
have := 0
|
||||
for _, g := range []bool{fc.Content != nil, fc.ContentBase64 != nil, fc.Delete} {
|
||||
for _, g := range []bool{fc.Content != nil, fc.ContentBase64 != nil, fc.Delete, fc.CopyFrom != nil} {
|
||||
if g {
|
||||
have++
|
||||
}
|
||||
}
|
||||
if have != 1 {
|
||||
return a, refuse("files[%d] (%s) needs exactly one of content, content_base64 or delete: true. Nothing was sent", i, fc.Path)
|
||||
return a, refuse("files[%d] (%s) needs exactly one of content, content_base64, delete: true or copy_from. Nothing was sent", i, fc.Path)
|
||||
}
|
||||
if fc.Content != nil && !utf8.ValidString(*fc.Content) {
|
||||
return a, refuse("files[%d].content (%s) is not UTF-8 text: give it as content_base64. Nothing was sent", i, fc.Path)
|
||||
@@ -186,6 +199,7 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) {
|
||||
if size > maxFileBytes {
|
||||
return a, refuse("files[%d] (%s) is %s, more than the %s one file of a commit may be. Nothing was sent", i, fc.Path, mib(size), mib(maxFileBytes))
|
||||
}
|
||||
a.Files[i].size = size
|
||||
total += size
|
||||
}
|
||||
if total > maxCommitBytes {
|
||||
@@ -318,8 +332,19 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
|
||||
return nil, fmt.Errorf("the forge did not take the patch: %w", err)
|
||||
}
|
||||
} else {
|
||||
var ops []map[string]any
|
||||
files := a.Files
|
||||
for _, fc := range a.Files {
|
||||
if fc.CopyFrom != nil {
|
||||
// Read, and judged against the limits, before anything is sent.
|
||||
var err error
|
||||
if files, out.Copies, err = expandCopies(ctx, f, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
var ops []map[string]any
|
||||
for _, fc := range files {
|
||||
sha, err := f.blobAt(ctx, repo, fc.Path, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// copy_from (novox/hq issue 1365): a file or a directory of another repository of the forge, copied by the
|
||||
// module itself, so a large move between repositories is not typed through the caller. Read through the
|
||||
// forge's API as the module's reads are (the contents of a path at a commit), every file at one commit sha
|
||||
// resolved once; the commit carries the files as it carries any others, within the same limits.
|
||||
|
||||
// CopyFrom is where a files entry takes its content from.
|
||||
type CopyFrom struct {
|
||||
Owner, Repo, Ref, Path string
|
||||
}
|
||||
|
||||
// CopiedFrom says, for one copy_from entry, what was read and at which commit.
|
||||
type CopiedFrom struct {
|
||||
Path string `json:"path"`
|
||||
From string `json:"from"`
|
||||
Source string `json:"source_commit"`
|
||||
Files int `json:"files"`
|
||||
Bytes int `json:"bytes"`
|
||||
}
|
||||
|
||||
var fullSHA = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
|
||||
// readCopyFrom reads one files[i].copy_from.
|
||||
func readCopyFrom(i int, v any) (*CopyFrom, error) {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, refuse("files[%d].copy_from is an object {owner, repo, ref, path}. Nothing was sent", i)
|
||||
}
|
||||
c := &CopyFrom{Owner: text(m, "owner"), Repo: text(m, "repo"), Ref: text(m, "ref"), Path: strings.Trim(text(m, "path"), "/")}
|
||||
if c.Owner == "" || c.Repo == "" || c.Ref == "" || c.Path == "" {
|
||||
return nil, refuse("files[%d].copy_from needs owner, repo, ref (a branch, tag or commit) and path. Nothing was sent", i)
|
||||
}
|
||||
if _, err := repoPath(c.Owner, c.Repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := segments("copy_from path", c.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// copySource is one file to copy: where it goes, its content (base64 as the forge answered, once read) and size.
|
||||
type copySource struct {
|
||||
target, source string
|
||||
size int
|
||||
}
|
||||
|
||||
// resolveRef is the full commit sha a ref names in a repository, "" when there is none.
|
||||
func (f *Forge) resolveRef(ctx context.Context, repo, ref string) (string, error) {
|
||||
if fullSHA.MatchString(ref) {
|
||||
return ref, nil
|
||||
}
|
||||
if _, err := segments("copy_from ref", ref); err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := f.branch(ctx, repo, ref)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if b != nil && b.Commit.ID != "" {
|
||||
return b.Commit.ID, nil
|
||||
}
|
||||
// Not a branch: a tag or a short commit, as the commit history reads it.
|
||||
var got []forgeCommit
|
||||
if err := f.GetJSON(ctx, repo+"/commits?sha="+queryEscape(ref)+"&limit=1&stat=false&verification=false&files=false", &got); err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if len(got) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return got[0].SHA, nil
|
||||
}
|
||||
|
||||
// contentEntry is one entry of the forge's answer for a path: a file or a symlink, or the entries of a directory.
|
||||
type contentEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
Encoding string `json:"encoding"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// contentsAt reads a path at a commit: a single entry for a file, a list for a directory; found false for none.
|
||||
func (f *Forge) contentsAt(ctx context.Context, repo, path, sha string) (one contentEntry, list []contentEntry, found bool, err error) {
|
||||
seg, err := segments("copy_from path", path)
|
||||
if err != nil {
|
||||
return one, nil, false, err
|
||||
}
|
||||
body, err := f.Do(ctx, "GET", repo+"/contents/"+seg+"?ref="+queryEscape(sha), nil, maxBody)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return one, nil, false, nil
|
||||
}
|
||||
return one, nil, false, err
|
||||
}
|
||||
if jsonInto(body, &list) == nil {
|
||||
return one, list, true, nil
|
||||
}
|
||||
if err := jsonInto(body, &one); err != nil {
|
||||
return one, nil, false, fmt.Errorf("the forge's answer about %s is not readable: %w", path, err)
|
||||
}
|
||||
return one, nil, true, nil
|
||||
}
|
||||
|
||||
// walkCopy lists every file under a directory at a commit, recursively; a symlink or anything not a file is refused.
|
||||
func (f *Forge) walkCopy(ctx context.Context, repo, label string, dir string, list []contentEntry, out *[]copySource, prefix, target string) error {
|
||||
for _, e := range list {
|
||||
p := e.Path
|
||||
if p == "" {
|
||||
p = dir + "/" + e.Name
|
||||
}
|
||||
switch e.Type {
|
||||
case "file":
|
||||
rel := strings.TrimPrefix(strings.TrimPrefix(p, prefix), "/")
|
||||
*out = append(*out, copySource{target: target + "/" + rel, source: p, size: int(e.Size)})
|
||||
case "dir":
|
||||
_, sub, found, err := f.contentsAt(ctx, repo, p, label)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("the forge listed %s but then did not have it", p)
|
||||
}
|
||||
if err := f.walkCopy(ctx, repo, label, p, sub, out, prefix, target); err != nil {
|
||||
return err
|
||||
}
|
||||
case "symlink":
|
||||
return refuse("%s is a symlink: copy_from copies files only. Nothing was sent", p)
|
||||
default:
|
||||
return refuse("%s is a %s: copy_from copies files only. Nothing was sent", p, e.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandCopies replaces every copy_from entry by the files it stands for, read at one commit per entry, and says
|
||||
// what was read. The limits count the copied files with the others before anything is read in full.
|
||||
func expandCopies(ctx context.Context, f *Forge, a CommitAsk) ([]FileChange, []CopiedFrom, error) {
|
||||
type plan struct {
|
||||
at int
|
||||
sha string
|
||||
sources []copySource
|
||||
from *CopyFrom
|
||||
repo string
|
||||
}
|
||||
var plans []plan
|
||||
count, total := 0, 0
|
||||
for i, fc := range a.Files {
|
||||
if fc.CopyFrom == nil {
|
||||
count++
|
||||
total += fc.size
|
||||
continue
|
||||
}
|
||||
c := fc.CopyFrom
|
||||
repo, err := repoPath(c.Owner, c.Repo)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var r Repo
|
||||
if err := f.GetJSON(ctx, repo, &r); err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, nil, refuse("files[%d].copy_from: the forge has no repository %s/%s. Nothing was sent", i, c.Owner, c.Repo)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
sha, err := f.resolveRef(ctx, repo, c.Ref)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if sha == "" {
|
||||
return nil, nil, refuse("files[%d].copy_from: %s/%s has no branch, tag or commit %q. Nothing was sent", i, c.Owner, c.Repo, c.Ref)
|
||||
}
|
||||
one, list, found, err := f.contentsAt(ctx, repo, c.Path, sha)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, nil, refuse("files[%d].copy_from: %s/%s has no %q at %s. Nothing was sent", i, c.Owner, c.Repo, c.Path, short(sha))
|
||||
}
|
||||
p := plan{at: i, sha: sha, from: c, repo: repo}
|
||||
if list != nil || one.Type == "dir" {
|
||||
if err := f.walkCopy(ctx, repo, sha, c.Path, list, &p.sources, c.Path, fc.Path); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
} else if one.Type == "file" {
|
||||
p.sources = []copySource{{target: fc.Path, source: c.Path, size: int(one.Size)}}
|
||||
} else {
|
||||
return nil, nil, refuse("files[%d].copy_from: %s is a %s, not a file or a directory: copy_from copies files only. Nothing was sent", i, c.Path, one.Type)
|
||||
}
|
||||
if len(p.sources) == 0 {
|
||||
return nil, nil, refuse("files[%d].copy_from: %s/%s has no file under %q at %s. Nothing was sent", i, c.Owner, c.Repo, c.Path, short(sha))
|
||||
}
|
||||
for _, s := range p.sources {
|
||||
count++
|
||||
total += s.size
|
||||
if s.size > maxFileBytes {
|
||||
return nil, nil, refuse("files[%d].copy_from: %s is %s, more than the %s one file of a commit may be. Nothing was sent", i, s.source, mib(s.size), mib(maxFileBytes))
|
||||
}
|
||||
}
|
||||
plans = append(plans, p)
|
||||
}
|
||||
if count > maxFiles || total > maxCommitBytes {
|
||||
return nil, nil, refuse("the commit would carry %d files, %s together (copied and given), more than the %d files and %s one commit carries: "+
|
||||
"split the copy by subdirectory into several commits. Nothing was sent", count, mib(total), maxFiles, mib(maxCommitBytes))
|
||||
}
|
||||
// The files are read now, each at its commit sha.
|
||||
var out []FileChange
|
||||
var said []CopiedFrom
|
||||
seen := map[string]bool{}
|
||||
for _, fc := range a.Files {
|
||||
if fc.CopyFrom == nil {
|
||||
if seen[fc.Path] {
|
||||
return nil, nil, refuse("%s is named twice in files. Nothing was sent", fc.Path)
|
||||
}
|
||||
seen[fc.Path] = true
|
||||
out = append(out, fc)
|
||||
}
|
||||
}
|
||||
for _, p := range plans {
|
||||
cf := CopiedFrom{Path: a.Files[p.at].Path, From: p.from.Owner + "/" + p.from.Repo + ":" + p.from.Path, Source: p.sha}
|
||||
sort.Slice(p.sources, func(i, j int) bool { return p.sources[i].target < p.sources[j].target })
|
||||
for _, s := range p.sources {
|
||||
if seen[s.target] {
|
||||
return nil, nil, refuse("%s is named twice in files (a copy_from reaches it as well). Nothing was sent", s.target)
|
||||
}
|
||||
seen[s.target] = true
|
||||
if _, err := segments("file path", s.target); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
one, _, found, err := f.contentsAt(ctx, p.repo, s.source, p.sha)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !found || one.Type != "file" {
|
||||
return nil, nil, refuse("%s/%s: %s is not a file at %s any more. Nothing was sent", p.from.Owner, p.from.Repo, s.source, short(p.sha))
|
||||
}
|
||||
if one.Encoding != "base64" {
|
||||
return nil, nil, fmt.Errorf("the forge answered %s without its content in base64 (encoding %q): nothing was sent", s.source, one.Encoding)
|
||||
}
|
||||
b64 := strings.NewReplacer("\n", "", "\r", "").Replace(one.Content)
|
||||
raw, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("the forge's content of %s is not base64: %w", s.source, err)
|
||||
}
|
||||
if len(raw) > maxFileBytes {
|
||||
return nil, nil, refuse("%s is %s, more than the %s one file of a commit may be. Nothing was sent", s.source, mib(len(raw)), mib(maxFileBytes))
|
||||
}
|
||||
cf.Files++
|
||||
cf.Bytes += len(raw)
|
||||
out = append(out, FileChange{Path: s.target, ContentBase64: &b64, size: len(raw)})
|
||||
}
|
||||
said = append(said, cf)
|
||||
}
|
||||
return out, said, nil
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// sourceRepo is another repository of the fake forge, answering the contents, branch, repository and commit
|
||||
// reads in the shapes the module already parses elsewhere (forge.go, reads.go, commit.go); a file's content
|
||||
// is the forge's base64 "content" with its "encoding".
|
||||
type sourceRepo struct {
|
||||
branches map[string]string
|
||||
trees map[string]map[string]string // commit -> path -> content
|
||||
symlinks map[string]bool // paths that are symlinks, in every tree
|
||||
moveTo map[string]string // branch -> commit it moves to right after it is first read
|
||||
tags map[string]string
|
||||
reads []string
|
||||
}
|
||||
|
||||
func (f *fakeForge) addSource(name string, s *sourceRepo) {
|
||||
if f.sources == nil {
|
||||
f.sources = map[string]*sourceRepo{}
|
||||
}
|
||||
f.sources[name] = s
|
||||
}
|
||||
|
||||
func (f *fakeForge) handleSource(w http.ResponseWriter, r *http.Request, path string) {
|
||||
rest := strings.TrimPrefix(path, "/api/v1/repos/acme/")
|
||||
name, rest, _ := strings.Cut(rest, "/")
|
||||
s := f.sources[name]
|
||||
if s == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
reply := func(v any) { _ = json.NewEncoder(w).Encode(v) }
|
||||
s.reads = append(s.reads, r.Method+" "+path+"?"+r.URL.RawQuery)
|
||||
switch {
|
||||
case rest == "":
|
||||
reply(map[string]any{"full_name": "acme/" + name, "default_branch": "main"})
|
||||
case strings.HasPrefix(rest, "branches/"):
|
||||
b := strings.TrimPrefix(rest, "branches/")
|
||||
c, ok := s.branches[b]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
reply(map[string]any{"name": b, "commit": map[string]any{"id": c}})
|
||||
if to, ok := s.moveTo[b]; ok {
|
||||
delete(s.moveTo, b)
|
||||
s.branches[b] = to
|
||||
}
|
||||
case rest == "commits":
|
||||
if c, ok := s.tags[r.URL.Query().Get("sha")]; ok {
|
||||
reply([]any{map[string]any{"sha": c}})
|
||||
return
|
||||
}
|
||||
reply([]any{})
|
||||
case strings.HasPrefix(rest, "contents/"):
|
||||
p := strings.TrimPrefix(rest, "contents/")
|
||||
tree, ok := s.trees[r.URL.Query().Get("ref")]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if content, ok := tree[p]; ok {
|
||||
typ := "file"
|
||||
if s.symlinks[p] {
|
||||
typ = "symlink"
|
||||
}
|
||||
reply(map[string]any{"type": typ, "path": p, "name": p[strings.LastIndex(p, "/")+1:], "sha": blob(content),
|
||||
"size": len(content), "encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte(content))})
|
||||
return
|
||||
}
|
||||
// A directory: its entries, each with its full path.
|
||||
entries := map[string]map[string]any{}
|
||||
for q, content := range tree {
|
||||
if !strings.HasPrefix(q, p+"/") {
|
||||
continue
|
||||
}
|
||||
sub := strings.TrimPrefix(q, p+"/")
|
||||
first, more, nested := strings.Cut(sub, "/")
|
||||
_ = more
|
||||
if nested {
|
||||
entries[first] = map[string]any{"name": first, "path": p + "/" + first, "type": "dir"}
|
||||
continue
|
||||
}
|
||||
typ := "file"
|
||||
if s.symlinks[q] {
|
||||
typ = "symlink"
|
||||
}
|
||||
entries[first] = map[string]any{"name": first, "path": q, "type": typ, "size": len(content), "sha": blob(content)}
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var names []string
|
||||
for n := range entries {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
list := []any{}
|
||||
for _, n := range names {
|
||||
list = append(list, entries[n])
|
||||
}
|
||||
reply(list)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func newSource() *sourceRepo {
|
||||
return &sourceRepo{
|
||||
branches: map[string]string{"main": "s0"},
|
||||
tags: map[string]string{"v1": "s0"},
|
||||
trees: map[string]map[string]string{
|
||||
"s0": {"lib/a.go": "package a\n", "lib/deep/b.go": "package b\n", "lib/deep/er/c.txt": "c\n", "top.md": "top\n", "other/x": "x\n"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func copyAsk(t *testing.T, files ...map[string]any) CommitAsk {
|
||||
return ask(t, map[string]any{"branch": "fix/1365-x", "files": filesArg(files...)})
|
||||
}
|
||||
|
||||
func cp(path, repo, ref, src string) map[string]any {
|
||||
return map[string]any{"path": path, "copy_from": map[string]any{"owner": "acme", "repo": repo, "ref": ref, "path": src}}
|
||||
}
|
||||
|
||||
// Seen failing first: with the expansion switched off the commit carried no copy and the answer named no source.
|
||||
func TestACopyFromAFileCommitsTheSourcesContentAndNamesItsCommit(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.addSource("src", newSource())
|
||||
got, err := commit(context.Background(), f.serve(), copyAsk(t, cp("vendor/top.md", "src", "main", "top.md")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.trees[got.Commit]["vendor/top.md"] != "top\n" {
|
||||
t.Fatalf("tree %v", f.trees[got.Commit])
|
||||
}
|
||||
if len(got.Copies) != 1 || got.Copies[0].Source != "s0" || got.Copies[0].Path != "vendor/top.md" || got.Copies[0].Files != 1 {
|
||||
t.Fatalf("copies %+v", got.Copies)
|
||||
}
|
||||
}
|
||||
|
||||
// Seen failing first, as above.
|
||||
func TestACopyFromADirectoryCopiesEveryFileUnderItToTheSameRelativePaths(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.addSource("src", newSource())
|
||||
got, err := commit(context.Background(), f.serve(), copyAsk(t, cp("pkg/lib", "src", "v1", "lib"),
|
||||
map[string]any{"path": "own.md", "content": "own\n"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tree := f.trees[got.Commit]
|
||||
want := map[string]string{"pkg/lib/a.go": "package a\n", "pkg/lib/deep/b.go": "package b\n", "pkg/lib/deep/er/c.txt": "c\n", "own.md": "own\n"}
|
||||
for p, c := range want {
|
||||
if tree[p] != c {
|
||||
t.Errorf("%s is %q, want %q", p, tree[p], c)
|
||||
}
|
||||
}
|
||||
if tree["pkg/lib/top.md"] != "" || tree["pkg/other/x"] != "" {
|
||||
t.Errorf("files outside the directory were copied: %v", tree)
|
||||
}
|
||||
if len(got.Copies) != 1 || got.Copies[0].Source != "s0" || got.Copies[0].Files != 3 || got.Copies[0].Bytes != len("package a\npackage b\nc\n") {
|
||||
t.Fatalf("copies %+v", got.Copies)
|
||||
}
|
||||
if len(got.Files) != 4 {
|
||||
t.Errorf("files %+v", got.Files)
|
||||
}
|
||||
}
|
||||
|
||||
// Seen failing first, as above: the files were read at the branch's new head.
|
||||
func TestEveryCopiedFileIsReadAtTheOneShaTheRefResolvedTo(t *testing.T) {
|
||||
f := newFake(t)
|
||||
s := newSource()
|
||||
s.trees["s1"] = map[string]string{"lib/a.go": "package MOVED\n", "lib/deep/b.go": "package MOVED\n", "lib/deep/er/c.txt": "MOVED\n"}
|
||||
s.moveTo = map[string]string{"main": "s1"}
|
||||
f.addSource("src", s)
|
||||
got, err := commit(context.Background(), f.serve(), copyAsk(t, cp("pkg", "src", "main", "lib")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.trees[got.Commit]["pkg/a.go"] != "package a\n" || got.Copies[0].Source != "s0" {
|
||||
t.Fatalf("tree %v, copies %+v", f.trees[got.Commit], got.Copies)
|
||||
}
|
||||
for _, r := range s.reads {
|
||||
if strings.Contains(r, "/contents/") && !strings.Contains(r, "ref=s0") {
|
||||
t.Errorf("a file was read at another ref: %s", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seen failing first: each case was taken or refused for another reason (or sent).
|
||||
func TestEveryRefusalOfACopySendsNothingAndSaysWhy(t *testing.T) {
|
||||
big := strings.Repeat("z", maxFileBytes+1)
|
||||
many := map[string]string{}
|
||||
for i := 0; i < maxFiles+1; i++ {
|
||||
many[fmt.Sprintf("many/f%03d", i)] = "m"
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
file map[string]any
|
||||
want string
|
||||
}{
|
||||
"unknown repository": {cp("t", "nope", "main", "top.md"), "no repository acme/nope"},
|
||||
"unknown ref": {cp("t", "src", "nothing", "top.md"), `no branch, tag or commit "nothing"`},
|
||||
"unknown path": {cp("t", "src", "main", "missing/dir"), `no "missing/dir"`},
|
||||
"symlink in source": {cp("t", "src", "main", "link"), "link is a symlink"},
|
||||
"symlink under dir": {cp("t", "src", "main", "withlink"), "withlink/l is a symlink"},
|
||||
"file over 4 MiB": {cp("t", "src", "main", "big"), "more than the 4.0 MiB"},
|
||||
"over 200 files": {cp("t", "src", "main", "many"), "201 files"},
|
||||
"with content": {map[string]any{"path": "t", "content": "x", "copy_from": cp("t", "src", "main", "top.md")["copy_from"]}, "copy_from together with content"},
|
||||
"with base64": {map[string]any{"path": "t", "content_base64": "eA==", "copy_from": cp("t", "src", "main", "top.md")["copy_from"]}, "copy_from together with content"},
|
||||
"with delete": {map[string]any{"path": "t", "delete": true, "copy_from": cp("t", "src", "main", "top.md")["copy_from"]}, "copy_from together with content"},
|
||||
"meets a given file": {cp("pkg", "src", "main", "lib"), "pkg/a.go is named twice"},
|
||||
} {
|
||||
f := newFake(t)
|
||||
s := newSource()
|
||||
s.trees["s0"]["link"] = "top.md"
|
||||
s.symlinks = map[string]bool{"link": true, "withlink/l": true}
|
||||
s.trees["s0"]["withlink/l"] = "top.md"
|
||||
s.trees["s0"]["withlink/ok"] = "ok"
|
||||
s.trees["s0"]["big/f"] = big
|
||||
for p, v := range many {
|
||||
s.trees["s0"][p] = v
|
||||
}
|
||||
f.addSource("src", s)
|
||||
files := []map[string]any{c.file}
|
||||
if name == "meets a given file" {
|
||||
files = append(files, map[string]any{"path": "pkg/a.go", "content": "mine"})
|
||||
}
|
||||
a, err := readCommitAsk(map[string]any{"owner": "acme", "repo": "app", "message": "m", "branch": "fix/1365-x", "files": filesArg(files...)})
|
||||
if err == nil {
|
||||
_, err = commit(context.Background(), f.serve(), a)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "Nothing was sent") {
|
||||
t.Errorf("%s: %v (want %q)", name, err, c.want)
|
||||
}
|
||||
if len(f.writes) != 0 || len(f.patches) != 0 || f.branches["fix/1365-x"] != "" {
|
||||
t.Errorf("%s: something was sent: %v %v", name, f.writes, f.branches)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seen failing first: the copy was not counted, so the commit was sent.
|
||||
func TestACopyOverTheTotalNamesItsCountAndSizeSoItCanBeSplit(t *testing.T) {
|
||||
f := newFake(t)
|
||||
s := newSource()
|
||||
chunk := strings.Repeat("q", maxFileBytes)
|
||||
for i := 0; i < 5; i++ {
|
||||
s.trees["s0"][fmt.Sprintf("heavy/f%d", i)] = chunk
|
||||
}
|
||||
f.addSource("src", s)
|
||||
_, err := commit(context.Background(), f.serve(), copyAsk(t, cp("h", "src", "main", "heavy")))
|
||||
if err == nil || !strings.Contains(err.Error(), "5 files, 20.0 MiB together") || !strings.Contains(err.Error(), "by subdirectory") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if len(f.writes) != 0 {
|
||||
t.Fatal("sent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFromIsInTheToolsSchemaAndTheReadme(t *testing.T) {
|
||||
var schema map[string]any
|
||||
for _, tl := range Tools(func() (*Forge, error) { return nil, fmt.Errorf("unset") }) {
|
||||
if tl.Name == "gitea_commit" {
|
||||
schema = tl.Input
|
||||
}
|
||||
}
|
||||
item := schema["properties"].(map[string]any)["files"].(map[string]any)["items"].(map[string]any)["properties"].(map[string]any)
|
||||
cf, ok := item["copy_from"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("copy_from is not in the schema: %v", item)
|
||||
}
|
||||
props := cf["properties"].(map[string]any)
|
||||
for _, k := range []string{"owner", "repo", "ref", "path"} {
|
||||
if _, ok := props[k]; !ok {
|
||||
t.Errorf("copy_from has no %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestACopyFromThatIsNotAnObjectIsRefused(t *testing.T) {
|
||||
_, err := readCommitAsk(map[string]any{"owner": "acme", "repo": "app", "message": "m", "branch": "b",
|
||||
"files": filesArg(map[string]any{"path": "x", "copy_from": "acme/src:top.md"})})
|
||||
if err == nil || !strings.Contains(err.Error(), "copy_from is an object") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ type fakeForge struct {
|
||||
noParents bool // the forge's answer to a write does not say the parents
|
||||
parentsBroken bool // and GET git/commits fails
|
||||
parents map[string]string // commit -> parent, as GET git/commits says it
|
||||
sources map[string]*sourceRepo
|
||||
}
|
||||
|
||||
type move struct {
|
||||
@@ -88,6 +89,10 @@ func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) {
|
||||
reply := func(v any) { _ = json.NewEncoder(w).Encode(v) }
|
||||
const repo = "/api/v1/repos/acme/app"
|
||||
path := r.URL.EscapedPath()
|
||||
if strings.HasPrefix(path, "/api/v1/repos/acme/") && !strings.HasPrefix(path, repo) {
|
||||
f.handleSource(w, r, path)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.Method == "GET" && path == repo:
|
||||
reply(map[string]any{"full_name": "acme/app", "default_branch": "main"})
|
||||
|
||||
@@ -51,7 +51,7 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool {
|
||||
"when it does not exist. Refused on the repository's default branch and on any branch a protection " +
|
||||
"rule names (novox/hq ADR 0266): a change reaches those only by a pull request whose head an " +
|
||||
"independent review approved and whose required checks passed (gitea_merge_pull_request). Carries " +
|
||||
"either files — each {path, content (text) | content_base64 | delete: true}, created or updated as " +
|
||||
"either files — each {path, content (text) | content_base64 | delete: true | copy_from {owner, repo, ref, path}: a file or directory of another repository, copied by the module}, created or updated as " +
|
||||
"the branch holds them — or patch, a unified diff as git diff writes it, which the forge applies. " +
|
||||
"At most 200 files, 4 MiB each and 16 MiB together, or a 4 MiB patch. Answers the commit it made and " +
|
||||
"the parent the forge made it on, read back from the branch, and says when the branch moved under " +
|
||||
@@ -77,6 +77,17 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool {
|
||||
"content": str("its whole new content, as UTF-8 text"),
|
||||
"content_base64": str("its whole new content, base64-encoded, for a file that is not text"),
|
||||
"delete": map[string]any{"type": "boolean", "description": "true to delete the file"},
|
||||
"copy_from": map[string]any{
|
||||
"type": "object",
|
||||
"description": "instead of content: copy a file, or every file under a directory (recursively, to the same relative paths under this path), of another repository of the forge, read at one commit sha resolved from ref once (the answer names it per entry). Files only: a symlink is refused. The copied files count against the limits; an unknown repository, ref or path, or a copy over the limits, is refused with nothing sent, naming the count and size so the copy can be split by subdirectory. Not together with content, content_base64 or delete",
|
||||
"properties": map[string]any{
|
||||
"owner": str("the source repository's owner"),
|
||||
"repo": str("the source repository's name"),
|
||||
"ref": str("a branch, tag or commit of the source repository"),
|
||||
"path": str("the file or directory to copy, from the source repository's root"),
|
||||
},
|
||||
"required": []string{"owner", "repo", "path", "ref"},
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -35,14 +36,33 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) {
|
||||
}
|
||||
var file, process map[string]any
|
||||
for _, r := range m.Resources {
|
||||
for _, k := range strs(r["takes-config"]) {
|
||||
if k == ConfigKey {
|
||||
file = r
|
||||
}
|
||||
if r["id"] == "repositories-file" {
|
||||
file = r
|
||||
}
|
||||
}
|
||||
if file == nil || file["type"] != "file" {
|
||||
t.Fatalf("no file takes the value %s", ConfigKey)
|
||||
t.Fatalf("no file repositories-file")
|
||||
}
|
||||
// 1128/AC1: a plain template the provisioner client fills, so an approved value lands at once: a file the
|
||||
// controller merges (merge, takes-config) takes a config change only on the node's next send (ADR 0348 §10).
|
||||
if file["merge"] != nil || file["takes-config"] != nil {
|
||||
t.Errorf("repositories-file is merged by the controller (merge %v, takes-config %v), want a plain template",
|
||||
file["merge"], file["takes-config"])
|
||||
}
|
||||
text, err := os.ReadFile("../../" + file["template"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(text), "${config:"+ConfigKey+"}") {
|
||||
t.Fatalf("the template %s does not say ${config:%s}: %s", file["template"], ConfigKey, text)
|
||||
}
|
||||
filled := filepath.Join(t.TempDir(), "repositories.json")
|
||||
value := `{"mesh-delivery":{"private":false,"description":"d"}}`
|
||||
if err := os.WriteFile(filled, []byte(strings.ReplaceAll(string(text), "${config:"+ConfigKey+"}", value)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, err := ReadValue(filled); err != nil || len(v) != 1 || v["mesh-delivery"].Description != "d" {
|
||||
t.Errorf("the filled template reads as %v, %v; want mesh-delivery", v, err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "process" && has(strs(r["restart-on"]), file["id"].(string)) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1141/AC1: a repository the program makes gets main's rule with an administrator's merge past a status refused,
|
||||
// as every repository's main has had since 2026-10-07.
|
||||
func TestNewRepositoryRefusesTheAdministratorsOverride(t *testing.T) {
|
||||
f := newFake()
|
||||
s, _ := syncer(t, f)
|
||||
run(t, s, Value{"mesh-delivery": {Description: "d"}})
|
||||
rule := f.repos["novox/mesh-delivery"].Rules[mainRule]
|
||||
if rule["block_admin_merge_override"] != true {
|
||||
t.Errorf("main lets an administrator merge past its statuses: %v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
// 1141/AC2: a narrowed main rule that still lets an administrator override is not right: it is put right, its
|
||||
// statuses kept, and a second pass changes nothing.
|
||||
func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) {
|
||||
f := newFake()
|
||||
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: {
|
||||
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []any{},
|
||||
"enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check", "other/x"},
|
||||
"block_admin_merge_override": false,
|
||||
}}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"mesh-delivery": {Description: "d"}})
|
||||
rule := repo.Rules[mainRule]
|
||||
if rule["block_admin_merge_override"] != true {
|
||||
t.Errorf("the administrator's override is still allowed: %v", rule)
|
||||
}
|
||||
for _, c := range []string{"mesh/merge-gate", "mesh/repo-check", "other/x"} {
|
||||
if !has(strs(rule["status_check_contexts"]), c) {
|
||||
t.Errorf("a status was dropped: %v", rule["status_check_contexts"])
|
||||
}
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
|
||||
t.Errorf("outcome %+v, want put right", r)
|
||||
}
|
||||
before := len(f.writes())
|
||||
run(t, s, Value{"mesh-delivery": {Description: "d"}})
|
||||
if got := f.writes(); len(got) != before {
|
||||
t.Errorf("a second pass wrote %s", strings.Join(got[before:], ", "))
|
||||
}
|
||||
}
|
||||
@@ -333,12 +333,14 @@ func statuses(rules ...rule) []string {
|
||||
}
|
||||
|
||||
// narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges,
|
||||
// requiring the statuses (ADR 0428 §5). A field the forge keeps beside these is left as it is.
|
||||
// requiring the statuses (ADR 0428 §5), and no administrator merging past them, as every repository's main has been
|
||||
// since 2026-10-07 (issue 1141). A field the forge keeps beside these is left as it is.
|
||||
func narrowed(r rule, toolsAccount string, also ...rule) map[string]any {
|
||||
return map[string]any{
|
||||
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []string{},
|
||||
"enable_merge_whitelist": true, "merge_whitelist_usernames": []string{toolsAccount},
|
||||
"enable_status_check": true, "status_check_contexts": statuses(append([]rule{r}, also...)...),
|
||||
"block_admin_merge_override": true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +352,8 @@ func isRight(r rule, toolsAccount string) bool {
|
||||
if m := strList(r["merge_whitelist_usernames"]); len(m) != 1 || m[0] != toolsAccount {
|
||||
return false
|
||||
}
|
||||
return r["enable_status_check"] == true && hasAll(strList(r["status_check_contexts"]))
|
||||
return r["enable_status_check"] == true && hasAll(strList(r["status_check_contexts"])) &&
|
||||
r["block_admin_merge_override"] == true
|
||||
}
|
||||
|
||||
func hasAll(have []string) bool {
|
||||
@@ -394,9 +397,6 @@ func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error)
|
||||
if n, _ := r["required_approvals"].(float64); n > approvals {
|
||||
approvals = n
|
||||
}
|
||||
if r["block_admin_merge_override"] == true {
|
||||
body["block_admin_merge_override"] = true
|
||||
}
|
||||
}
|
||||
if approvals > 0 {
|
||||
body["required_approvals"] = approvals
|
||||
|
||||
@@ -225,6 +225,7 @@ func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
|
||||
more := f.add("novox/more", map[string]map[string]any{mainRule: {
|
||||
"enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check", "mesh/delivery"},
|
||||
"block_admin_merge_override": true, // right since issue 1141
|
||||
}}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"only": {}, "more": {}})
|
||||
|
||||
@@ -1 +1 @@
|
||||
{}
|
||||
{"repositories": ${config:repositories}}
|
||||
|
||||
@@ -94,6 +94,8 @@ export const QUERY_KEYS: ReadonlySet<string> = new Set([
|
||||
"limit", "page", "state", "type", "q", "sort", "order", "since", "before", "ref", "sha", "path", "labels",
|
||||
"milestones", "keyword", "stat", "verification", "files", "not", "mode", "include_desc", "archived", "private",
|
||||
"exclusive", "uid", "priority_repo_id", "created_by", "assigned_by", "mentioned_by", "whitespace", "binary",
|
||||
// Read flags of the forge's tree listing (gitea_commit_trees, novox/hq issue 1234): they widen what one read answers, never what a call may write.
|
||||
"recursive", "per_page",
|
||||
]);
|
||||
|
||||
/** The query a call sends: only allow-listed keys, each as given; refused when any other key is there. */
|
||||
|
||||
@@ -322,11 +322,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:mesh-state}/repositories.json",
|
||||
"mode": "0600",
|
||||
"template": "files/repositories.json",
|
||||
"merge": "json",
|
||||
"takes-config": [
|
||||
"repositories"
|
||||
]
|
||||
"template": "files/repositories.json"
|
||||
},
|
||||
{
|
||||
"id": "consumers",
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
// Fixtures follow Gitea's GitTreeResponse as its API documents it (swagger: sha, url, tree[{path, mode, type, size,
|
||||
// sha, url}], truncated, page, total_count) and as the forge answers GET /repos/{o}/{r}/git/trees/{sha}; the entry
|
||||
// fields are those, with sha values written out. The live capture (1234/AC6): GET
|
||||
// /repos/novox/mesh-delivery/git/trees/7f61817 (non-recursive) answered the COMMIT's sha at the top, not the root
|
||||
// tree's; the root tree is computed from the root's own entries, as git does.
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const COMMIT = "c".repeat(40);
|
||||
const h = (c: string) => c.repeat(40);
|
||||
const entry = (path: string, type: "tree" | "blob", sha: string) => ({
|
||||
path, mode: type === "tree" ? "040000" : "100644", type, size: type === "tree" ? 0 : 12, sha, url: `u/${sha}`,
|
||||
});
|
||||
const LISTING = [
|
||||
entry("README.md", "blob", h("1")),
|
||||
entry("module.json", "blob", h("2")),
|
||||
entry("modules", "tree", h("3")),
|
||||
entry("modules/a", "tree", h("4")),
|
||||
entry("modules/a/module.json", "blob", h("5")),
|
||||
entry("modules/a/main.go", "blob", h("6")),
|
||||
entry("modules/b", "tree", h("7")),
|
||||
entry("modules/b/c", "tree", h("8")),
|
||||
entry("modules/b/c/module.json", "blob", h("9")),
|
||||
entry("modules/d", "tree", h("a")),
|
||||
entry("modules/d/notes.md", "blob", h("b")),
|
||||
];
|
||||
|
||||
/** git's own hash of a tree from its entries (an independent restatement for the fixtures): mode without leading zero. */
|
||||
function gitTree(es: { path: string; type: string; sha: string; mode: string }[]): string {
|
||||
const ordered = es.map((e) => ({ ...e, key: e.path + (e.type === "tree" ? "/" : "") })).sort((a, b) => Buffer.compare(Buffer.from(a.key), Buffer.from(b.key)));
|
||||
const body = Buffer.concat(ordered.map((e) => Buffer.concat([Buffer.from(`${e.mode.replace(/^0+/, "")} ${e.path}\0`), Buffer.from(e.sha, "hex")])));
|
||||
return createHash("sha1").update(Buffer.concat([Buffer.from(`tree ${body.length}\0`), body])).digest("hex");
|
||||
}
|
||||
const ROOT = gitTree(LISTING.filter((e) => !e.path.includes("/")));
|
||||
|
||||
async function serve(pages: any[]) {
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
const asked: string[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
asked.push(req.url ?? "");
|
||||
const url = new URL(req.url ?? "", "http://x");
|
||||
if (url.pathname !== `/api/v1/repos/novox/hq/git/trees/${COMMIT}` || url.searchParams.get("recursive") !== "true") {
|
||||
res.statusCode = 404;
|
||||
return res.end("{}");
|
||||
}
|
||||
const page = Number(url.searchParams.get("page") ?? 1);
|
||||
res.end(JSON.stringify(pages[page - 1] ?? { sha: COMMIT, tree: [], truncated: false, page, total_count: 0 }));
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
|
||||
return { client, server, asked };
|
||||
}
|
||||
|
||||
test("a commit's trees: the root and the tree of each directory holding a module.json", async () => {
|
||||
// 1234/AC1 and 1234/AC4
|
||||
const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
|
||||
try {
|
||||
const got = await client.commitTrees("novox", "hq", COMMIT);
|
||||
assert.deepEqual(got, { root: ROOT, trees: { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") } });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a listing the forge pages is read page by page", async () => {
|
||||
// 1234/AC1
|
||||
const { client, server, asked } = await serve([
|
||||
{ sha: COMMIT, tree: LISTING.slice(0, 5), truncated: true, page: 1, total_count: LISTING.length },
|
||||
{ sha: COMMIT, tree: LISTING.slice(5, 9), truncated: true, page: 2, total_count: LISTING.length },
|
||||
{ sha: COMMIT, tree: LISTING.slice(9), truncated: false, page: 3, total_count: LISTING.length },
|
||||
]);
|
||||
try {
|
||||
const got = await client.commitTrees("novox", "hq", COMMIT);
|
||||
assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") });
|
||||
assert.equal(asked.length, 3, `asked ${asked.join(" ")}`);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a listing the forge says is cut fails, naming the repository and the commit, and answers no trees", async () => {
|
||||
// 1234/AC2
|
||||
const { client, server } = await serve([{ sha: COMMIT, tree: LISTING, truncated: true, page: 1, total_count: LISTING.length }]);
|
||||
try {
|
||||
await assert.rejects(client.commitTrees("novox", "hq", COMMIT), (err: Error) => {
|
||||
assert.match(err.message, /novox\/hq/);
|
||||
assert.ok(err.message.includes(COMMIT), err.message);
|
||||
assert.match(err.message, /cut/);
|
||||
return true;
|
||||
});
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a sha that is not a full commit, or no owner or repo, is refused before the forge is asked", async () => {
|
||||
// 1234/AC3
|
||||
const { getGiteaTools } = await import("../tools/index.ts");
|
||||
const { client, server, asked } = await serve([]);
|
||||
try {
|
||||
const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!;
|
||||
assert.ok(tool, "the tool is listed");
|
||||
for (const args of [
|
||||
{ owner: "novox", repo: "hq", sha: "abc1234" },
|
||||
{ owner: "novox", repo: "hq", sha: "main" },
|
||||
{ owner: "novox", repo: "hq", sha: "" },
|
||||
{ owner: "novox", repo: "hq", sha: "c".repeat(41) },
|
||||
{ owner: "", repo: "hq", sha: COMMIT },
|
||||
{ owner: "novox", repo: " ", sha: COMMIT },
|
||||
]) await assert.rejects(tool.run(args as any), /./, JSON.stringify(args));
|
||||
assert.equal(asked.length, 0, `the forge was asked ${asked.join(" ")}`);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("gitea_open_pulls says each pull request's head branch beside its head sha", async () => {
|
||||
// 1234/AC5
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
const server = createServer((req, res) => {
|
||||
const url = req.url ?? "";
|
||||
const send = (v: unknown) => res.end(JSON.stringify(v));
|
||||
if (url.startsWith("/api/v1/repos/issues/search")) {
|
||||
return send(url.includes("page=1") ? [{ number: 7, repository: { owner: "novox", name: "hq", full_name: "novox/hq" } }] : []);
|
||||
}
|
||||
if (url.startsWith("/api/v1/repos/novox/hq/pulls/7")) {
|
||||
return send({ number: 7, state: "open", base: { ref: "main" }, head: { ref: "feat/x", sha: "aaa1111" }, html_url: "h" });
|
||||
}
|
||||
if (url.includes("/branch_protections")) { res.statusCode = 403; return res.end("{}"); }
|
||||
if (url.startsWith("/api/v1/repos/novox/hq/branches/main")) return send({ name: "main" });
|
||||
if (url.includes("/commits/aaa1111/status")) return send({ statuses: [] });
|
||||
res.statusCode = 404;
|
||||
res.end("{}");
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
|
||||
try {
|
||||
const [p] = await client.openPulls();
|
||||
assert.deepEqual(p, { owner: "novox", repo: "hq", number: 7, base: "main", head: "feat/x", head_sha: "aaa1111", html_url: "h",
|
||||
required: [], statuses: [], checks: {}, labels: [] });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the root tree is git's hash of the root's own entries, never the listing's top sha (the commit's)", async () => {
|
||||
// 1234/AC6, from the live capture named in the header: novox/mesh-delivery at 7f61817.
|
||||
const commit = "7f618177d6773d8d2d2b9e0b10e13b85ec700c59";
|
||||
const listing = [
|
||||
entry("README.md", "blob", "a087d22cbcbecce42256315671ff7e4d6ca85a7c"),
|
||||
entry("merge-check.sh", "blob", "c4ef9a3dd6390b0edde7e4137095553897cecfb0"),
|
||||
entry("modules", "tree", "31c0abb312ee6ce4693a7aacdd8b3fba871d6cf3"),
|
||||
entry("modules/x", "tree", h("d")),
|
||||
entry("modules/x/module.json", "blob", h("e")),
|
||||
];
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
const server = createServer((req, res) => {
|
||||
res.end(JSON.stringify(new URL(req.url ?? "", "http://x").pathname.endsWith(`/git/trees/${commit}`)
|
||||
? { sha: commit, tree: listing, truncated: false, page: 1, total_count: listing.length } : {}));
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
|
||||
try {
|
||||
const got = await client.commitTrees("novox", "mesh-delivery", commit);
|
||||
assert.equal(got.root, "659bcaaac6c16972ec380267a4eba69d15558c26");
|
||||
assert.deepEqual(got.trees, { "modules/x": h("d") });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
@@ -539,7 +539,7 @@ test("the tools register once there is a way to a token, and the first call mint
|
||||
"gitea_list_branches",
|
||||
"gitea_delete_branch",
|
||||
"gitea_branch_protection_get", "gitea_branch_protection_set",
|
||||
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_contains",
|
||||
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains",
|
||||
"gitea_list_labels", "gitea_create_label",
|
||||
"gitea_api",
|
||||
],
|
||||
|
||||
@@ -523,6 +523,22 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
input: {},
|
||||
run: async () => ({ pulls: await gitea.openPulls() }),
|
||||
},
|
||||
{
|
||||
name: "gitea_commit_trees",
|
||||
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
sha: { type: "string", description: "the commit, its full 40-hex sha" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
|
||||
const sha = String(args.sha ?? "").trim();
|
||||
if (!owner || !repo) throw new Error("name the repository: owner and repo");
|
||||
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha");
|
||||
return gitea.commitTrees(owner, repo, sha);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_contains",
|
||||
description: "Whether a commit is contained in another — an ancestor of it, or the same — from the forge's compare of the two: what mesh-delivery reads before it says a held merge is covered by a later one (novox/hq ADR 0270). Commits only, by sha or the start of one. Reads only.",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
// Keycloak runs the moved path with the grant file still open for its consumers not moved (issue 937), fixed for
|
||||
// keycloak by issue 1332: mesh-mail-web, grafana and car-hunter are not moved, so without this fix keycloak never
|
||||
// made their clients and held them for retirement. These tests drive the SDK's harness.Moved exactly as main.go
|
||||
// wires it, against a fake Keycloak and an in-memory bus — no real backend.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
"git.novox.be/novox/mesh-sdk/go/provider/harness"
|
||||
)
|
||||
|
||||
// grantGives writes a grant file (MESH_RECEIVES) naming consumers the plans do not move (issue 937): each one's
|
||||
// secret file, and the json the grant path reads.
|
||||
func grantGives(t *testing.T, given ...map[string]any) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, g := range given {
|
||||
as := g["as"].(string)
|
||||
secret := filepath.Join(dir, as+".secret")
|
||||
if err := os.WriteFile(secret, []byte("pw-"+as+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g["secret"] = secret
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"requirement": ProvisionName, "given": given})
|
||||
at := filepath.Join(dir, "mesh.json")
|
||||
if err := os.WriteFile(at, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// noOpPlan puts one assignment the bus can read that binds nothing of keycloak's, so the moved path has plans it
|
||||
// could read (m.asked answers "read", issue 241) even when nothing is moved.
|
||||
func noOpPlan(bus *bucketBus, node, module string) {
|
||||
raw, _ := json.Marshal(map[string]any{"node": node, "module": module})
|
||||
_ = bus.Put(ctx, provider.AssignmentsBucket, node+"."+module, raw)
|
||||
}
|
||||
|
||||
// waitUntil polls cond until it is true or the timeout passes.
|
||||
func waitUntil(t *testing.T, timeout time.Duration, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("condition never held within %s", timeout)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// movedWorld builds keycloak's moved path, as main.go wires it, against a fake Keycloak and a bucket bus. Nothing
|
||||
// runs until start is called, so a test can seed the backend first; it is stopped when the test ends.
|
||||
func movedWorld(t *testing.T, h *harness.Harness) (f *fakeKeycloak, start func()) {
|
||||
t.Helper()
|
||||
var o OidcClients
|
||||
f, o = oidcWorld(t)
|
||||
h.Adapter = provisioner{clients: o, announce: func(string, map[string]any) {}, log: func(string, ...any) {}}
|
||||
if h.Every == 0 {
|
||||
h.Every = 5 * time.Millisecond
|
||||
}
|
||||
consumersFile := filepath.Join(t.TempDir(), "consumers.json")
|
||||
if err := os.WriteFile(consumersFile, []byte("[]"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, _, _, err := moved(consumersFile, h, testIssuer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bus := &bucketBus{buckets: map[string]map[string][]byte{}}
|
||||
noOpPlan(bus, "elsewhere", "unrelated")
|
||||
p := provider.New(provider.Options{Node: h.Node, Module: "keycloak", Bus: bus,
|
||||
Vault: provider.ConsumersFile{Paths: []string{consumersFile}},
|
||||
Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.PerConsumer, Serves: servedValues(testIssuer)}},
|
||||
Make: run.Make, Gone: run.Gone, Every: h.Every, Logf: func(string, ...any) {}})
|
||||
return f, func() {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
go func() { _ = run.Run(runCtx, p, bus) }()
|
||||
}
|
||||
}
|
||||
|
||||
// 1332/AC1: a consumer only the grant file gives (oidc-client for novox.mesh-mail-web, not moved) is wanted and
|
||||
// Adapter.Create is asked for its client, though keycloak runs on the moved path.
|
||||
func TestAGrantFileConsumerNotMovedIsMadeOnTheMovedPath(t *testing.T) {
|
||||
mailWeb := "mesh_novox_mail_web"
|
||||
receives := grantGives(t, map[string]any{"as": mailWeb, "node": "novox", "values": map[string]any{
|
||||
"label": "mail-web", "endpoint": "web", "callback": "/login/generic_oauth", "name": "mail.example.org"}})
|
||||
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "novox"}
|
||||
f, start := movedWorld(t, h)
|
||||
start()
|
||||
waitUntil(t, time.Second, func() bool {
|
||||
found := false
|
||||
f.set(func() {
|
||||
for _, c := range f.clients {
|
||||
if c["clientId"] == mailWeb {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
})
|
||||
return found
|
||||
})
|
||||
}
|
||||
|
||||
// 1332/AC2: a grant-file consumer whose client already exists in the backend (mesh_ace_grafana) counts as asked and
|
||||
// is not held for retirement on the moved path.
|
||||
func TestAGrantFileConsumerAlreadyInKeycloakIsNotRetiredOnTheMovedPath(t *testing.T) {
|
||||
grafana := "mesh_ace_grafana"
|
||||
receives := grantGives(t, map[string]any{"as": grafana, "node": "ace", "values": map[string]any{
|
||||
"label": "grafana", "endpoint": "web", "callback": "/login/generic_oauth", "name": "grafana.example.org"}})
|
||||
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "ace",
|
||||
StablePasses: 1, StableFor: time.Millisecond}
|
||||
f, start := movedWorld(t, h)
|
||||
// Seed the backend with the client the mesh made earlier, exactly as grafana's already is — before anything
|
||||
// runs, so the harness's one-time seed (issue 1332) sees it.
|
||||
f.set(func() {
|
||||
f.clients["existing-grafana"] = Rep{"id": "existing-grafana", "clientId": grafana, "enabled": true,
|
||||
"attributes": map[string]any{Mark: "true"}}
|
||||
})
|
||||
start()
|
||||
// Many passes, well past StablePasses and StableFor if the login were ever counted unasked.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
f.set(func() {
|
||||
c := f.clients["existing-grafana"]
|
||||
if c["enabled"] == false {
|
||||
t.Fatalf("retired %v though its grant still gives it (issue 1332)", c)
|
||||
}
|
||||
if _, retired := attributes(c)[MarkRetired]; retired {
|
||||
t.Fatalf("marked retired %v though its grant still gives it (issue 1332)", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -51,15 +51,9 @@ func main() {
|
||||
say("%v — the provisioner cannot run without it", err)
|
||||
} else if consumers := handedConsumersFile(os.Getenv, os.Stat); consumers != "" {
|
||||
// Handed to the provisioner (novox/hq ADR 0373): the provider helper answers the consumers its
|
||||
// machine's client fills the file with; the grant directory is not read. Retirement is the
|
||||
// harness's, never run, so its guards and its tools stand (issue 805).
|
||||
h = &harness.Harness{
|
||||
Resource: ProvisionName,
|
||||
Adapter: provisioner{clients: OidcClients{KC: kc, Realm: realm}, guard: guard, announce: announce, log: logStderr},
|
||||
Log: logStderr,
|
||||
Announce: announce,
|
||||
Node: os.Getenv("MESH_NODE"),
|
||||
}
|
||||
// machine's client fills the file with, and the grant file still gives the consumers not moved
|
||||
// (issue 937, 1332). Retirement is the harness's, never run, so its guards and its tools stand (issue 805).
|
||||
h = movedHarness(os.Getenv, provisioner{clients: OidcClients{KC: kc, Realm: realm}, guard: guard, announce: announce, log: logStderr})
|
||||
run, p, bus, err := moved(consumers, h, issuer)
|
||||
if err != nil {
|
||||
say("%v", err)
|
||||
@@ -101,6 +95,20 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// movedHarness is the harness the moved path shares with the SDK's helper: what main wires together, pulled out so
|
||||
// it can be built and inspected without starting the process. Receives is MESH_RECEIVES, read exactly as the grant
|
||||
// path reads it (novox/hq issue 937), so the grant file still gives every consumer not moved (novox/hq issue 1332).
|
||||
func movedHarness(getenv func(string) string, adapter harness.Adapter) *harness.Harness {
|
||||
return &harness.Harness{
|
||||
Resource: ProvisionName,
|
||||
Receives: getenv("MESH_RECEIVES"),
|
||||
Adapter: adapter,
|
||||
Log: logStderr,
|
||||
Announce: announce,
|
||||
Node: getenv("MESH_NODE"),
|
||||
}
|
||||
}
|
||||
|
||||
// handedConsumersFile is the consumers' file when keycloak is handed to the provisioner: the file MESH_CONSUMERS_FILE
|
||||
// names, which the controller places only where it hands the module. Empty, where it is not set or the file is not
|
||||
// there, and keycloak then reads the grant directory exactly as it always did (issue 805).
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
// What main wires for the moved path (novox/hq issue 1332): the harness it builds from the environment, the same
|
||||
// way the grant path reads its own settings.
|
||||
|
||||
import "testing"
|
||||
|
||||
// 1332/AC3: the moved-path harness, built from the environment, reads MESH_RECEIVES the way the grant path does
|
||||
// (main.go's other branch, line 67), as postgres's moved harness does (postgres-provider/main.go:43).
|
||||
func TestTheMovedPathHarnessReadsMESH_RECEIVESAsTheGrantPathDoes(t *testing.T) {
|
||||
env := map[string]string{"MESH_RECEIVES": "/grants/mesh.json", "MESH_NODE": "novox"}
|
||||
getenv := func(k string) string { return env[k] }
|
||||
h := movedHarness(getenv, provisioner{})
|
||||
if h.Receives != env["MESH_RECEIVES"] {
|
||||
t.Fatalf("Receives = %q, want %q — the grant file still gives every consumer not moved (issue 937), "+
|
||||
"read exactly as MESH_RECEIVES on the grant path, as postgres's moved harness does "+
|
||||
"(postgres-provider/main.go:43)", h.Receives, env["MESH_RECEIVES"])
|
||||
}
|
||||
}
|
||||
@@ -43,10 +43,16 @@ func valuesOf(e provider.Entry) map[string]any {
|
||||
// moved is keycloak handed to the provisioner: the SDK's moved path (harness.Moved) answering its consumers, and the
|
||||
// harness (never run) whose retirement and tools it shares. What differs from postgres is only this declaration
|
||||
// (issue 849).
|
||||
//
|
||||
// **A consumer not moved is still the grant file's** (issue 937, ADR 0349 clause 17, fixed here by issue 1332):
|
||||
// only a moved consumer's plan names keycloak as its provider, so the controller still gives every other consumer
|
||||
// through keycloak's grant file (MESH_RECEIVES) until R1 (issue 682) — mesh-mail-web, grafana and car-hunter among
|
||||
// them. Each client it gives is made and checked by the harness's grant pass exactly as on the grant path; a client
|
||||
// a plan names is the helper's alone.
|
||||
func moved(consumersFile string, h *harness.Harness, issuer string) (*harness.Moved, *provider.Provider, provider.Bus, error) {
|
||||
return harness.NewMoved(consumersFile, h, harness.MovedSpec{Module: "keycloak", Provision: ProvisionName,
|
||||
Offer: provider.Offer{Credential: provider.PerConsumer, Serves: servedValues(issuer)},
|
||||
Login: func(e provider.Entry) string { return e.As },
|
||||
Values: valuesOf,
|
||||
Noun: "client id", Made: "client", Backend: "Keycloak", WithGrants: false})
|
||||
Noun: "client id", Made: "client", Backend: "Keycloak", WithGrants: true})
|
||||
}
|
||||
|
||||
@@ -318,24 +318,74 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
id := fmt.Sprintf("merge-%d-%s-%d", d.Number, shortOf(d.Commit, 12), r.Asks)
|
||||
ask := composeMergeAsk(id, d, group, members, heads, now)
|
||||
taken := []*Delivery{}
|
||||
var reads []memberRead
|
||||
for _, m := range members {
|
||||
h.runOf(m, now)
|
||||
m.Ready.AskID, m.Ready.Ask, m.Ready.Heads, m.Ready.AskedAt, m.Ready.Asks = id, &ask, heads, now, max(m.Ready.Asks, r.Asks)
|
||||
h.keep(m)
|
||||
taken = append(taken, m)
|
||||
reads = append(reads, readOf(m, m.Commit))
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s passed its checks: the operator is asked to Approve its merge into main (%s, ask %d)",
|
||||
d.ID, id, r.Asks)
|
||||
gid := ""
|
||||
if group != "" {
|
||||
gid = d.Group
|
||||
}
|
||||
return "the operator's Approve is asked (" + id + ")", func() {
|
||||
if err := h.Operator.Ask(ask); err != nil {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
// A group is not asked while a member's required status is not a success at its head (issue 1275): the group
|
||||
// says which, and is asked once every member may merge.
|
||||
var holdMember, holdSays string
|
||||
var readErr error
|
||||
for _, m := range reads {
|
||||
if gid == "" {
|
||||
break
|
||||
}
|
||||
says, _, err := h.notASuccess(m)
|
||||
if err != nil {
|
||||
readErr = err
|
||||
break
|
||||
}
|
||||
if says != "" {
|
||||
holdMember, holdSays = m.id, says
|
||||
break
|
||||
}
|
||||
}
|
||||
undo := func() {
|
||||
for _, m := range taken {
|
||||
if m.Ready != nil && m.Ready.AskID == id {
|
||||
m.Ready.AskID, m.Ready.Ask, m.Ready.Heads = "", nil, nil
|
||||
h.keep(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if readErr != nil || holdSays != "" {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
undo()
|
||||
if g := h.groups[gid]; g != nil && holdSays != "" {
|
||||
if g.Hold == nil || g.Hold.Kind != "unasked" || g.Hold.Says != holdSays || g.Hold.Member != holdMember {
|
||||
h.Logf("[mesh-delivery] group %s is not asked for its Approve: %s waits on a required status: %s", gid, holdMember, holdSays)
|
||||
g.Hold = &GroupHold{Kind: "unasked", Member: holdMember, Says: holdSays, Since: h.Now()}
|
||||
h.keepGroup(g)
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
h.Logf("[mesh-delivery] group %s: the required statuses cannot be read, so the operator is not asked yet: %v", gid, readErr)
|
||||
}
|
||||
h.again("a group's Approve held for a required status")
|
||||
return
|
||||
}
|
||||
if g := func() *Group { h.mu.Lock(); defer h.mu.Unlock(); return h.groups[gid] }(); g != nil && g.Hold != nil && g.Hold.Kind == "unasked" {
|
||||
h.mu.Lock()
|
||||
g.Hold = nil
|
||||
h.keepGroup(g)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
if err := h.Operator.Ask(ask); err != nil {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
undo()
|
||||
h.again("the operator's Approve, which could not be asked")
|
||||
h.Logf("[mesh-delivery] the operator could not be asked about %s (%v); asked again in %s", d.ID, err, askAgainAfter)
|
||||
}
|
||||
|
||||
@@ -306,6 +306,17 @@ func (r *runtime) answer(m rpcMessage) {
|
||||
return toolAnswer(map[string]any{"asked": "check-1"}), nil
|
||||
case "gitea.gitea_open_pulls":
|
||||
return toolAnswer(map[string]any{"pulls": pulls}), nil
|
||||
case "gitea.gitea_commit_statuses":
|
||||
// Green: both merge checks a success at the head asked, as the gitea module answers.
|
||||
sha, _ := a.Body["sha"].(string)
|
||||
if sha == "" {
|
||||
sha = "0000000"
|
||||
}
|
||||
return toolAnswer(map[string]any{"commit": sha, "statuses": []any{
|
||||
map[string]any{"context": "mesh/merge-gate", "state": "success"},
|
||||
map[string]any{"context": "mesh/repo-check", "state": "success"}},
|
||||
"merge": map[string]any{"branch": "main", "protected": true, "required": []string{"mesh/merge-gate", "mesh/repo-check"},
|
||||
"blocking": []any{}, "mergeable": true, "says": "main requires mesh/merge-gate, mesh/repo-check"}}), nil
|
||||
case "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status", "gitea.gitea_merge_pull_request",
|
||||
"gitea.gitea_add_comment":
|
||||
return toolAnswer(map[string]any{}), nil
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
@@ -51,8 +53,58 @@ func (h *Holder) AskDecided(id, state string, w *asks.Warrant) {
|
||||
// CommitStatus takes a commit status set on the forge: one of the merge checks (mesh/merge-gate, mesh/repo-check) says a
|
||||
// check started, answered, or was put back, so the forge's open pull requests are read to see which heads still wait on
|
||||
// one (SeeOpenPulls). A status of any other context shows nothing the merge needs.
|
||||
//
|
||||
// A merge check turning success for the head an approved, ready, not-yet-merged delivery's merge was refused at is
|
||||
// itself an event that decides whether it may merge (issue 1144): the merge tool may have refused it on a check still
|
||||
// pending, which this status answers. The merge is asked again at once (mergeAgainNow), under the lock, so the next
|
||||
// look asks it; no timer does this (issue 1115). A status of another context, another commit or a state other than
|
||||
// success changes nothing.
|
||||
func (h *Holder) CommitStatus(e ForgeStatusEvent) {
|
||||
if slices.Contains(mergeChecks, e.Context) {
|
||||
h.wantForge()
|
||||
if !slices.Contains(mergeChecks, e.Context) {
|
||||
return
|
||||
}
|
||||
h.wantForge()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if e.State != "success" {
|
||||
h.withdrawForStatus(e)
|
||||
return
|
||||
}
|
||||
repository := strings.ToLower(e.Owner + "/" + e.Repo)
|
||||
for _, d := range h.deliveries {
|
||||
if !strings.EqualFold(d.Repository, repository) || d.Ready == nil || d.Ready.Merged != nil || d.MergedAs != "" ||
|
||||
d.State.Final() {
|
||||
continue
|
||||
}
|
||||
head := d.Commit
|
||||
if sha := d.Ready.Heads[d.ID]; sha != "" {
|
||||
head = sha
|
||||
}
|
||||
if sameCommit(head, e.SHA) {
|
||||
h.mergeAgainNow(d) // its approved head's check turned success on the forge: the merge is asked again at once
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withdrawForStatus takes back the ask of an Approve nobody answered yet when a merge check of a head it covers is no
|
||||
// longer a success (issue 1275, AC5): the answer to a withdrawn ask merges nothing, and the operator is asked again once
|
||||
// every member may merge. Under the lock.
|
||||
func (h *Holder) withdrawForStatus(e ForgeStatusEvent) {
|
||||
repository := strings.ToLower(e.Owner + "/" + e.Repo)
|
||||
done := map[string]bool{}
|
||||
for _, d := range h.deliveries {
|
||||
r := d.Ready
|
||||
if !strings.EqualFold(d.Repository, repository) || r == nil || r.AskID == "" || r.Approved != nil || r.Declined != nil || done[r.AskID] {
|
||||
continue
|
||||
}
|
||||
head := d.Commit
|
||||
if sha := r.Heads[d.ID]; sha != "" {
|
||||
head = sha
|
||||
}
|
||||
if sameCommit(head, e.SHA) {
|
||||
done[r.AskID] = true
|
||||
h.cancels = append(h.cancels, h.withdrawAsk(r.AskID, fmt.Sprintf("%s of %s is %s at its head %s", e.Context, d.ID, e.State, shortOf(head, 12))))
|
||||
h.Poke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ var errAskedAgain = &askedAgainError{}
|
||||
type askedAgainError struct{}
|
||||
|
||||
func (*askedAgainError) Error() string {
|
||||
return "the forge refused gitea_merge_pull_request: The check of head merged into trunk was asked"
|
||||
return "the forge refused gitea_merge_pull_request: novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. The check of head cccccccccccc merged into trunk bbbbbbbbbbbb was asked; took 3s. Ask again at cccccccccccc once both statuses pass. Nothing was merged"
|
||||
}
|
||||
|
||||
// Issue 1022 (ADR 0421 §5): the review record is read and put in the ask; it is never a gate.
|
||||
|
||||
@@ -504,17 +504,17 @@ func (f *fakeForge) Statuses(owner, repo string, number int, commit, base string
|
||||
f.asked = append(f.asked, fmt.Sprintf("%s/%s#%d@%s base=%s", owner, repo, number, commit, base))
|
||||
out := &ForgeChecks{}
|
||||
if number > 0 {
|
||||
p, ok := f.pulls[fmt.Sprintf("%s/%s#%d", owner, repo, number)]
|
||||
if !ok {
|
||||
if p, ok := f.pulls[fmt.Sprintf("%s/%s#%d", owner, repo, number)]; ok {
|
||||
out.Pull = &p
|
||||
if commit == "" {
|
||||
commit = p.HeadSHA
|
||||
}
|
||||
if base == "" {
|
||||
base = p.Base
|
||||
}
|
||||
} else if commit == "" {
|
||||
return nil, fmt.Errorf("no pull request %d", number)
|
||||
}
|
||||
out.Pull = &p
|
||||
if commit == "" {
|
||||
commit = p.HeadSHA
|
||||
}
|
||||
if base == "" {
|
||||
base = p.Base
|
||||
}
|
||||
}
|
||||
for key, said := range f.statuses {
|
||||
at, context, _ := strings.Cut(key, " ")
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A group is merged whole or not at all, in its order (issue 1275, novox/hq ADR 0418).
|
||||
//
|
||||
// - Before the operator is asked, and again before any member is merged, every member's required statuses are read at
|
||||
// its head on the forge; one that is not a success holds the group, and nothing is merged.
|
||||
// - The members are merged one after the other in the group's order, and the pass stops at the first refusal: no later
|
||||
// member is merged.
|
||||
// - A group held or half merged is said as a stalled line, urgent, until every member is merged or a person decides.
|
||||
|
||||
// memberRead is what a status read of one member needs, taken under the lock.
|
||||
type memberRead struct {
|
||||
id, owner, repo, base, head string
|
||||
number int
|
||||
}
|
||||
|
||||
// readOf is a delivery as a status read needs it, at the head the Approve or the ask covers.
|
||||
func readOf(d *Delivery, head string) memberRead {
|
||||
if head == "" {
|
||||
head = d.Commit
|
||||
}
|
||||
return memberRead{id: d.ID, owner: d.Owner(), repo: d.Repo(), base: d.Base, head: head, number: d.Number}
|
||||
}
|
||||
|
||||
// notAsSuccess reads a member's required statuses at its head and says, in the forge's words, which are not a success;
|
||||
// empty when all are. ownOnly is whether only the member's own repository check is missing or pending. An error is a
|
||||
// read that could not be made: the member then counts as not mergeable.
|
||||
func (h *Holder) notASuccess(m memberRead) (says string, ownOnly bool, err error) {
|
||||
fc, err := h.Forge.Statuses(m.owner, m.repo, m.number, m.head, m.base)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if fc.Merge.Mergeable == nil {
|
||||
return "", false, fmt.Errorf("the forge could not say whether %s may merge: %s", m.id, fc.Merge.Says)
|
||||
}
|
||||
if *fc.Merge.Mergeable {
|
||||
return "", false, nil
|
||||
}
|
||||
var parts []string
|
||||
ownOnly = len(fc.Merge.Blocking) > 0
|
||||
for _, b := range fc.Merge.Blocking {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", b.Context, b.State))
|
||||
if b.Context != ctxRepo || (b.State != "pending" && b.State != "missing") {
|
||||
ownOnly = false
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s requires %s; not a success: %s", fc.Merge.Branch, strings.Join(fc.Merge.Required, ", "),
|
||||
strings.Join(parts, ", ")), ownOnly, nil
|
||||
}
|
||||
|
||||
// unmergedLive are a group's members not yet merged and not over, in the group's order. Under the lock.
|
||||
func (h *Holder) unmergedLive(g *Group) []*Delivery {
|
||||
var out []*Delivery
|
||||
for _, d := range h.membersOf(g) {
|
||||
if d.MergedAs == "" && !d.State.Final() && (d.Ready == nil || d.Ready.Merged == nil) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// groupPass is the one pass that merges a group's members, made by the first of them in the group's order that may be
|
||||
// merged; nil when it is not d's turn, or a member is not approved at its head. Under the lock; the pass runs outside it.
|
||||
func (h *Holder) groupPass(g *Group, d *Delivery, now time.Time) func() {
|
||||
order := h.unmergedLive(g)
|
||||
if len(order) == 0 || order[0] != d {
|
||||
return nil
|
||||
}
|
||||
var pass []passMember
|
||||
for _, m := range order {
|
||||
c := h.covering(m)
|
||||
if m.Ready == nil || m.Ready.Declined != nil || c == nil || c.Approved == nil || c.Heads[m.ID] != m.Commit || h.readyWaits(m) != "" {
|
||||
return nil // the others are not approved at their heads yet: nothing is merged before they are
|
||||
}
|
||||
pass = append(pass, passMember{read: readOf(m, c.Heads[m.ID]), heads: c.Heads})
|
||||
}
|
||||
for _, m := range order {
|
||||
m.Ready.Tries++
|
||||
m.Ready.LastTry, m.Ready.merging = now, true
|
||||
h.keep(m)
|
||||
}
|
||||
gid := g.ID
|
||||
return func() { h.mergeGroupPass(gid, pass) }
|
||||
}
|
||||
|
||||
// passMember is one member of a pass as it was taken under the lock.
|
||||
type passMember struct {
|
||||
read memberRead
|
||||
heads map[string]string
|
||||
}
|
||||
|
||||
// mergeGroupPass reads every member's required statuses, and only when all are a success merges the members in order,
|
||||
// stopping at the first the merge tool refuses.
|
||||
func (h *Holder) mergeGroupPass(gid string, pass []passMember) {
|
||||
release := func(from int, retry bool) {
|
||||
for _, p := range pass[from:] {
|
||||
if d := h.deliveries[p.read.id]; d != nil && d.Ready != nil {
|
||||
d.Ready.merging = false
|
||||
if retry {
|
||||
d.Ready.LastTry = time.Time{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range pass {
|
||||
says, ownOnly, err := h.notASuccess(p.read)
|
||||
if err == nil && says == "" {
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] group %s: the required statuses of %s cannot be read, so nothing is merged yet: %v", gid, p.read.id, err)
|
||||
release(0, true)
|
||||
h.again("a group's merge whose required statuses could not be read")
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
var own func()
|
||||
if g := h.groups[gid]; g != nil {
|
||||
if g.Hold == nil || g.Hold.Kind != "blocked" || g.Hold.Says != says || g.Hold.Member != p.read.id {
|
||||
h.Logf("[mesh-delivery] group %s is approved and NOT merged: %s at %s: %s", gid, p.read.id, shortOf(p.read.head, 12), says)
|
||||
}
|
||||
since := time.Time{}
|
||||
if g.Hold != nil && g.Hold.Kind == "blocked" {
|
||||
since = g.Hold.Since
|
||||
}
|
||||
if since.IsZero() {
|
||||
since = h.Now()
|
||||
}
|
||||
g.Hold = &GroupHold{Kind: "blocked", Member: p.read.id, Says: says, Since: since}
|
||||
h.keepGroup(g)
|
||||
}
|
||||
if d := h.deliveries[p.read.id]; ownOnly && d != nil && d.Ready != nil {
|
||||
own = h.askOwnAfterRefusal(d) // its own repository check is what is missing: asked, once for the head
|
||||
}
|
||||
release(0, true)
|
||||
h.again("a group's merge held for a required status")
|
||||
h.mu.Unlock()
|
||||
if own != nil {
|
||||
own()
|
||||
}
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
if g := h.groups[gid]; g != nil && g.Hold != nil {
|
||||
g.Hold = nil
|
||||
h.keepGroup(g)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
for i, p := range pass {
|
||||
if h.mergeOne(p.read.id, p.heads) {
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
release(i+1, false)
|
||||
if g := h.groups[gid]; g != nil {
|
||||
h.Logf("[mesh-delivery] group %s: the merge of %s was refused, so none of the members after it is merged", gid, p.read.id)
|
||||
h.keepGroup(g)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// groupLines are the groups that are held or half merged, as urgent stalled lines the controller raises as conditions:
|
||||
// one for each, on its first member not merged, until every member is merged or a person decides (issue 1275). A member
|
||||
// the merge tool refused is said by readyLines, with its group; this adds a group held for a required status, and a
|
||||
// group half merged with no refusal. Under the lock.
|
||||
func (h *Holder) groupLines(now time.Time) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, g := range h.groups {
|
||||
live := h.unmergedLive(g)
|
||||
if len(live) == 0 {
|
||||
continue
|
||||
}
|
||||
first, refused, running, merged := live[0], false, false, 0
|
||||
for _, d := range live {
|
||||
if d.Ready != nil && (d.Ready.RefusedSince != nil || d.Ready.Failed != "" || d.Ready.Refused != "") {
|
||||
refused = true
|
||||
}
|
||||
if d.Ready != nil && d.Ready.merging {
|
||||
running = true
|
||||
}
|
||||
}
|
||||
for _, d := range h.membersOf(g) {
|
||||
if d.MergedAs != "" || (d.Ready != nil && d.Ready.Merged != nil) {
|
||||
merged++
|
||||
}
|
||||
}
|
||||
heldOn := h.deliveries[holdMember(g)]
|
||||
switch {
|
||||
// A held member the merge tool refused is said by readyLines already: one line for an id, not two.
|
||||
case g.Hold != nil && g.Hold.Kind == "blocked" && !running && !(heldOn != nil && heldOn.Ready != nil && heldOn.Ready.RefusedSince != nil):
|
||||
out = append(out, StalledLine{ID: g.Hold.Member, Number: numberOf(h.deliveries[g.Hold.Member]), State: first.State,
|
||||
For: now.Sub(g.Hold.Since).Round(time.Second).String(), Bound: "0s", Severity: "urgent",
|
||||
H2: "none: the required status turns success, which asks the merge again — or the operator's decision",
|
||||
Says: fmt.Sprintf("group %s is approved and nothing is merged (merged %d of %d): %s waits on a required status "+
|
||||
"at its head: %s. Needed: that status turns success (its check asked again), or the operator decides.",
|
||||
g.ID, merged, len(h.membersOf(g)), g.Hold.Member, g.Hold.Says)})
|
||||
case merged > 0 && !refused && !running:
|
||||
out = append(out, StalledLine{ID: first.ID, Number: first.Number, State: first.State,
|
||||
For: now.Sub(g.Updated).Round(time.Second).String(), Bound: "0s", Severity: "urgent",
|
||||
H2: "none: every member merged, or the operator decides",
|
||||
Says: fmt.Sprintf("group %s is half merged (%d of %d members merged): %s is not merged. Needed: it merges "+
|
||||
"(its Approve and checks hold at its head), or the operator decides.", g.ID, merged, len(h.membersOf(g)), first.ID)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func numberOf(d *Delivery) int {
|
||||
if d == nil {
|
||||
return 0
|
||||
}
|
||||
return d.Number
|
||||
}
|
||||
|
||||
func holdMember(g *Group) string {
|
||||
if g.Hold == nil {
|
||||
return ""
|
||||
}
|
||||
return g.Hold.Member
|
||||
}
|
||||
@@ -89,25 +89,26 @@ func TestAnApprovedGroupWhoseTrunkMovedIsCheckedAgainAndMergedOnTheSameApprove(t
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0418 §4: a member the forge refuses is merged again on its fresh check, while the other merged in the same pass.
|
||||
func TestAMemberTheForgeRefusesIsMergedAgainWhileTheOtherMergedInTheSamePass(t *testing.T) {
|
||||
// ADR 0418 §4, issue 1275/AC3: a member the forge refuses stops the pass (the members after it are not asked), and is
|
||||
// merged again on its fresh check, the others after it in the same pass.
|
||||
func TestAMemberTheForgeRefusesStopsThePassAndIsMergedAgainOnItsFreshCheck(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
w.forge.refuse = errAskedAgain
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("both members are asked in the same pass: %v", w.forge.merges)
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the pass stops at the first refusal: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.refuse = nil
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("a refused member is asked again with no event, on a timer or at each look: %v", w.forge.merges)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: w.h.groups["feat/g"].Check.Asked, Verdict: "pass", Summary: "composed again"})
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 4 {
|
||||
t.Fatalf("a refused member is asked again on the next composed verdict: %v", w.forge.merges)
|
||||
if len(w.forge.merges) != 3 {
|
||||
t.Fatalf("a refused member is asked again on the next composed verdict, then the next: %v", w.forge.merges)
|
||||
}
|
||||
if d := w.delivery(IDOf(gCat, gHk)); d.Ready.Merged == nil {
|
||||
t.Fatalf("not merged: %+v", d.Ready)
|
||||
|
||||
@@ -2670,11 +2670,12 @@ func refusedAfter(d *Delivery, at time.Time) bool {
|
||||
}
|
||||
|
||||
// ownStatusPending is whether the merge tool refused a merge because a status of the member's own merge check is
|
||||
// pending while its gate is not: the forge's "not a success: mesh/repo-check (pending)" (gitea merge.ts, mergeableUnder).
|
||||
// pending or missing (issue 1276) while its gate is not: the forge's "not a success: mesh/repo-check (pending)" (gitea merge.ts, mergeableUnder).
|
||||
// A refusal that also names mesh/merge-gate is the group's composed check's to answer, as is a verdict out of date.
|
||||
func ownStatusPending(answer string) bool {
|
||||
_, listed, ok := strings.Cut(answer, "not a success:")
|
||||
return ok && strings.Contains(listed, "mesh/repo-check (pending)") && !strings.Contains(listed, "mesh/merge-gate")
|
||||
return ok && (strings.Contains(listed, "mesh/repo-check (pending)") || strings.Contains(listed, "mesh/repo-check (missing)")) &&
|
||||
!strings.Contains(listed, "mesh/merge-gate")
|
||||
}
|
||||
|
||||
// askOwnAfterRefusal asks a member's own check once for its head, after the merge tool refused it for a pending status of
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const time30s = 31 * time.Second
|
||||
|
||||
// Issue 1275 (the 2026-10-19 18:05 group): a group is merged whole or not at all, in its order, and a group that is held or
|
||||
// half merged is said loudly. Issue 1276/AC1: a refusal naming the member's own repository check as missing asks that check.
|
||||
|
||||
const (
|
||||
gate, repoCheck = "mesh/merge-gate", "mesh/repo-check"
|
||||
)
|
||||
|
||||
// statusOf sets a member head's two merge checks on the forge, and makes both required.
|
||||
func setChecks(w *world, repository, sha, gateState, repoState string) {
|
||||
w.forge.required = []string{gate, repoCheck}
|
||||
for ctx, state := range map[string]string{gate: gateState, repoCheck: repoState} {
|
||||
key := repository + "@" + sha + " " + ctx
|
||||
if state == "" {
|
||||
delete(w.forge.statuses, key) // no status at all: the forge says it is missing
|
||||
continue
|
||||
}
|
||||
w.forge.statuses[key] = state + " said → x"
|
||||
}
|
||||
}
|
||||
|
||||
func allGreen(w *world) {
|
||||
setChecks(w, gCat, gHk, "success", "success")
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
}
|
||||
|
||||
func refusedWords(repository string) error {
|
||||
return errors.New("the forge refused gitea_merge_pull_request: " + repository + " is not merged: main requires " +
|
||||
"mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check (missing). A pull request merges once its checks succeed")
|
||||
}
|
||||
|
||||
func urgentLines(w *world) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.Severity == "urgent" {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1275/AC2: an approved group whose member has a required status that is not a success at its head when the merge starts
|
||||
// is merged not at all, though the first member in order is fine; the group says which member and which status.
|
||||
func TestAnApprovedGroupWithAMemberWhoseRequiredStatusIsNotASuccessMergesNothing(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
setChecks(w, gCtl, gHc, "success", "") // the second member's repository check went missing after the ask
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("merged with a required status missing: %v", w.forge.merges)
|
||||
}
|
||||
lines := urgentLines(w)
|
||||
if len(lines) != 1 || !strings.Contains(lines[0].Says, "feat/g") || !strings.Contains(lines[0].Says, IDOf(gCtl, gHc)) ||
|
||||
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
|
||||
t.Fatalf("the held group is not said as one urgent line naming group, member, status and need: %+v", lines)
|
||||
}
|
||||
// The status turns success: the whole group merges.
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-controller", SHA: gHc, Context: repoCheck, State: "success"})
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 || len(urgentLines(w)) != 0 {
|
||||
t.Fatalf("merges %v, lines %+v; wanted both merged and no line", w.forge.merges, urgentLines(w))
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC2 with 1276/AC1: a member whose own repository check is the missing status has that check asked, once.
|
||||
func TestAHeldGroupWhoseMemberLacksItsOwnRepositoryCheckAsksThatCheck(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
setChecks(w, gCtl, gHc, "success", "")
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own checks %v, composed %v; wanted the member's own check once", own, composed)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC3: the merge tool refuses the first member in order; the second is not merged, and the group is said urgent,
|
||||
// naming the group, the refused member and the forge's words.
|
||||
func TestARefusedFirstMemberStopsTheGroupAndIsSaidUrgent(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || !strings.Contains(w.forge.merges[0], "mesh-catalog#2") {
|
||||
t.Fatalf("wanted only the first member asked: %v", w.forge.merges)
|
||||
}
|
||||
lines := urgentLines(w)
|
||||
if len(lines) != 1 || lines[0].ID != IDOf(gCat, gHk) || !strings.Contains(lines[0].Says, "feat/g") ||
|
||||
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
|
||||
t.Fatalf("not said as one urgent line: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC4: a group half merged (the second member refused after the first merged) stays raised, pass after pass, until
|
||||
// the second is merged; then it goes.
|
||||
func TestAHalfMergedGroupStaysRaisedUntilEveryMemberIsMerged(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: refusedWords("novox/mesh-controller#1")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
for range 3 {
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
lines := urgentLines(w)
|
||||
if len(lines) != 1 || lines[0].ID != IDOf(gCtl, gHc) || !strings.Contains(lines[0].Says, "feat/g") {
|
||||
t.Fatalf("the half merged group is not raised: %+v", lines)
|
||||
}
|
||||
}
|
||||
// A half merged group with no refusal on record (the pass died between members) is raised too.
|
||||
d := w.delivery(IDOf(gCtl, gHc))
|
||||
d.Ready.RefusedSince = nil
|
||||
if lines := urgentLines(w); len(lines) != 1 || !strings.Contains(lines[0].Says, "half merged") {
|
||||
t.Fatalf("a half merged group without a refusal is not raised: %+v", lines)
|
||||
}
|
||||
w.forge.refuseRepo = nil
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.h.PullMerged(merged(gCtl, 1, gHc, gT1))
|
||||
w.settleAll()
|
||||
if lines := urgentLines(w); len(lines) != 0 {
|
||||
t.Fatalf("the group is whole and still raised: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC1: a group with a member whose required status is not a success is not asked its Approve; the group says which
|
||||
// member and status; it is asked once every member may merge.
|
||||
func TestAGroupIsNotAskedWhileAMembersRequiredStatusIsNotASuccess(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
setChecks(w, gCat, gHk, "success", "success") // the composed verdict set every member's gate
|
||||
setChecks(w, gCtl, gHc, "success", "pending")
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
|
||||
Members: []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
|
||||
w.settleAll()
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked with a required status pending: %d asks", w.op.count())
|
||||
}
|
||||
if g.Hold == nil || g.Hold.Kind != "unasked" || g.Hold.Member != IDOf(gCtl, gHc) || !strings.Contains(g.Hold.Says, "mesh/repo-check (pending)") {
|
||||
t.Fatalf("the group does not say which member and status it waits for: %+v", g.Hold)
|
||||
}
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 || g.Hold != nil {
|
||||
t.Fatalf("asks %d, hold %+v; wanted the group asked once its statuses are green", w.op.count(), g.Hold)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC5: a member's required status turning not-success while the ask waits withdraws the ask at the router; the
|
||||
// answer to the withdrawn ask merges nothing; the group is asked again once every member may merge.
|
||||
func TestAStatusThatTurnsWhileTheAskWaitsWithdrawsItAndItsAnswerMergesNothing(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
ask := w.op.open()[0]
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-catalog", SHA: gHk, Context: repoCheck, State: "pending"})
|
||||
setChecks(w, gCat, gHk, "success", "pending")
|
||||
w.settleAll()
|
||||
if len(w.op.cancelled) != 1 || w.op.cancelled[0] != ask {
|
||||
t.Fatalf("the ask was not withdrawn at the router: %v", w.op.cancelled)
|
||||
}
|
||||
w.op.answer(ask, OptionApprove) // an answer that arrives late
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("the answer to a withdrawn ask merged: %v", w.forge.merges)
|
||||
}
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("asked again while a status was still pending: %d", w.op.count())
|
||||
}
|
||||
setChecks(w, gCat, gHk, "success", "success")
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if w.op.count() != 2 {
|
||||
t.Fatalf("not asked again once every member may merge: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// 1276/AC1: given a closed group whose member's merge was refused with "not a success: mesh/repo-check (missing)", then
|
||||
// the member's own check is asked, not the composed check.
|
||||
func TestAClosedGroupsMemberRefusedForItsOwnMissingStatusAsksItsOwnCheck(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: refusedWords("novox/mesh-controller#1")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own checks %v, composed checks %v; wanted the member's own check once and no composed check", own, composed)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC3 (review round 1): a member that is both refused and held for a required status is said by one stalled line,
|
||||
// not two with the same id.
|
||||
func TestAMemberBothRefusedAndHeldForAStatusIsOneStalledLine(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
setChecks(w, gCat, gHk, "success", "") // now its status is missing too; the next pass is held
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-catalog", SHA: gHk, Context: repoCheck, State: "success"})
|
||||
w.h.mergeAgainNow(w.delivery(IDOf(gCat, gHk)))
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if g := w.h.groups["feat/g"]; g.Hold == nil {
|
||||
t.Fatalf("the group was not held for the missing status")
|
||||
}
|
||||
n := 0
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.ID == IDOf(gCat, gHk) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("%d stalled lines for one member: %+v", n, w.h.Stalled())
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6: a member refused because its verdict is out of date (the trunk moved; its fresh check runs) is a warning at
|
||||
// once, and urgent only when the group is not merged within ten minutes; the pass stops at the refusal all the same.
|
||||
func TestAnOutOfDateRefusalIsAWarningAtOnceAndUrgentAfterTenMinutes(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errAskedAgain
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the pass did not stop at the refusal: %v", w.forge.merges)
|
||||
}
|
||||
sev := func() []string {
|
||||
var out []string
|
||||
for _, l := range w.h.Stalled() {
|
||||
out = append(out, l.Severity)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if got := sev(); len(got) != 1 || got[0] != "warning" {
|
||||
t.Fatalf("an out of date refusal is %v at once; wanted one warning", got)
|
||||
}
|
||||
w.later(9 * time.Minute)
|
||||
if got := sev(); len(got) != 1 || got[0] != "warning" {
|
||||
t.Fatalf("at nine minutes it is %v; wanted still a warning", got)
|
||||
}
|
||||
w.later(2 * time.Minute)
|
||||
if got := sev(); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("after ten minutes it is %v; wanted urgent", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6: every other refusal (here a missing status) is urgent at once.
|
||||
func TestAMissingStatusRefusalIsUrgentAtOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || lines[0].Severity != "urgent" {
|
||||
t.Fatalf("a missing status refusal is not urgent at once: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func severities(w *world) []string {
|
||||
var out []string
|
||||
for _, l := range w.h.Stalled() {
|
||||
out = append(out, l.Severity)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1275/AC6 (b): the same out of date refusal on a LATER member, after the earlier one merged, is a warning at once and
|
||||
// urgent after ten minutes: the half merged group does not say it urgent at once.
|
||||
func TestAnOutOfDateRefusalOnALaterMemberIsAWarningThenUrgent(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: errAskedAgain}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
w.settleAll()
|
||||
if got := severities(w); len(got) != 1 || got[0] != "warning" {
|
||||
t.Fatalf("a later member's out of date refusal is %v at once; wanted one warning", got)
|
||||
}
|
||||
w.later(11 * time.Minute)
|
||||
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("after ten minutes it is %v; wanted urgent", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6 (c): words naming an out of date check and also a missing status are urgent at once.
|
||||
func TestARefusalNamingOutOfDateAndAMissingStatusIsUrgentAtOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: novox/mesh-catalog#2 is not merged: its verdict was " +
|
||||
"judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. The check of head cccc merged into trunk bbbb was " +
|
||||
"asked. main requires mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check (missing)")
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("a refusal with two causes is %v; wanted urgent at once", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6 (d): words mesh-delivery cannot classify are urgent at once.
|
||||
func TestARefusalInUnknownWordsIsUrgentAtOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: something nobody foresaw")
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("an unclassified refusal is %v; wanted urgent at once", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The merge tool's refusals, copied from gitea merge.ts with their line, `${...}` filled in.
|
||||
var mergeToolRefusals = []struct {
|
||||
line string
|
||||
words string
|
||||
outOfDated bool
|
||||
}{
|
||||
{"merge.ts:121", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. " +
|
||||
"Its check is already asked and not yet answered (building), so it is not asked again; took 3s. Ask again at cccccccccccc once " +
|
||||
"both statuses pass: this tool never merges by itself (novox/hq ADR 0323). A check that never answers is asked again with " +
|
||||
"mesh-delivery.recheck. Nothing was merged", true},
|
||||
{"merge.ts:147", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. " +
|
||||
"The check of head cccccccccccc merged into trunk bbbbbbbbbbbb was asked; took 3s. Ask again at cccccccccccc once both " +
|
||||
"statuses pass: this tool never merges by itself (novox/hq ADR 0323). In a busy repository another merge may move the trunk " +
|
||||
"again first; this is then refused and asked once more. A check that never answers is asked again with " +
|
||||
"mesh-delivery.recheck. Nothing was merged", true},
|
||||
{"merge.ts:144", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb, " +
|
||||
"and its check could not be asked: the forge is away. Nothing was merged", false},
|
||||
{"merge.ts:156", "novox/app#7 is not merged: main requires mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check " +
|
||||
"(pending). A pull request merges once its checks succeed", false},
|
||||
{"merge.ts:165", "novox/app#7 is not merged: its verdict read siblings, and which ones is not held here, so the group cannot " +
|
||||
"be weighed. Ask its check again at cccccccccccc. Nothing was merged", false},
|
||||
{"merge.ts:172", "novox/app#7 is not merged: its verdict was judged beside 1 sibling(s), and a sibling is not ready. A member " +
|
||||
"judged beside its siblings merges once its group passed at the heads that verdict read (novox/hq ADR 0382, 0421). " +
|
||||
"Nothing was merged", false},
|
||||
}
|
||||
|
||||
// 1275/AC6 (e): only the merge tool's two out of date refusals (:121, :147) are warnings; the one saying its check could
|
||||
// not be asked (:144) and every other is urgent at once.
|
||||
func TestOnlyTheMergeToolsTwoOutOfDateRefusalsAreWarnings(t *testing.T) {
|
||||
for _, c := range mergeToolRefusals {
|
||||
if got := outOfDateRefusal(c.words); got != c.outOfDated {
|
||||
t.Errorf("%s: outOfDateRefusal = %v, wanted %v", c.line, got, c.outOfDated)
|
||||
}
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: " + c.words)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
want := "urgent"
|
||||
if c.outOfDated {
|
||||
want = "warning"
|
||||
}
|
||||
if got := severities(w); len(got) != 1 || got[0] != want {
|
||||
t.Errorf("%s: severity %v at once, wanted %s", c.line, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6 (f): the severity never steps down: a missing status refusal, then an out of date one, stays urgent.
|
||||
func TestASeverityOnceUrgentNeverStepsDownToAWarning(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.forge.refuseRepo = map[string]error{gCat: errAskedAgain}
|
||||
w.h.mergeAgainNow(w.delivery(IDOf(gCat, gHk)))
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if d := w.delivery(IDOf(gCat, gHk)); !outOfDateRefusal(d.Ready.Answer) {
|
||||
t.Fatalf("the second refusal was not the out of date one: %q", d.Ready.Answer)
|
||||
}
|
||||
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("severity %v after an out of date refusal that followed an urgent one; wanted urgent", got)
|
||||
}
|
||||
}
|
||||
@@ -968,12 +968,24 @@ type Group struct {
|
||||
Closed bool `json:"closed,omitempty"`
|
||||
// Said is the derived state last said, so a change of it is said once.
|
||||
Said string `json:"said,omitempty"`
|
||||
// Hold is why the group's merge or its Approve's ask is held back by a member's required status that is not a
|
||||
// success (issue 1275); nil when nothing is.
|
||||
Hold *GroupHold `json:"hold,omitempty"`
|
||||
// SaidStatus is the mesh/delivery-group status last written for it: a change of the status a state takes is
|
||||
// written too, though the state did not change (novox/hq issue 902).
|
||||
SaidStatus string `json:"said_status,omitempty"`
|
||||
Owed []Effect `json:"owed,omitempty"`
|
||||
}
|
||||
|
||||
// GroupHold is a group held back from its Approve or its merge because a member's required status is not a success.
|
||||
type GroupHold struct {
|
||||
// Kind is "unasked" (the Approve is not asked) or "blocked" (the approved merge is not started: nothing is merged).
|
||||
Kind string `json:"kind"`
|
||||
Member string `json:"member"`
|
||||
Says string `json:"says"`
|
||||
Since time.Time `json:"since"`
|
||||
}
|
||||
|
||||
// GroupCheck is a group's composed check.
|
||||
type GroupCheck struct {
|
||||
Asked string `json:"asked"`
|
||||
|
||||
@@ -62,6 +62,9 @@ type ReadyRun struct {
|
||||
// OwnAskedAt is when its own check was asked after the merge tool refused it for a pending status of its own (issue
|
||||
// 1049): once for the head this run is for.
|
||||
OwnAskedAt time.Time `json:"own_asked_at,omitempty"`
|
||||
// Urgent is set once a refusal of this head was one that is urgent at once: its severity never steps down to a
|
||||
// warning after (issue 1275, AC6).
|
||||
Urgent bool `json:"urgent,omitempty"`
|
||||
// RefusedSince is when the merge tool began refusing it without a break.
|
||||
RefusedSince *time.Time `json:"refused_since,omitempty"`
|
||||
// Merged is when the merge tool merged it.
|
||||
@@ -202,6 +205,13 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
if !r.LastTry.IsZero() {
|
||||
continue // refused: asked again at the next event that decides whether it may merge (mergeAgainNow), with no timer
|
||||
}
|
||||
if g := h.groups[d.Group]; d.Group != "" && g != nil {
|
||||
// A group is merged in one pass, in its order, and stops at the first refusal (issue 1275).
|
||||
if a := h.groupPass(g, d, now); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
continue
|
||||
}
|
||||
r.Tries++
|
||||
r.LastTry, r.merging = now, true
|
||||
h.keep(d)
|
||||
@@ -330,48 +340,55 @@ func (h *Holder) groupTurnToMerge(d *Delivery) string {
|
||||
// the heads the Approve covers, each `owner/repo@<full sha>` (novox/hq ADR 0421 §9), and refuses a pull request whose
|
||||
// head differs from the one approved.
|
||||
func (h *Holder) askMerge(id string, heads map[string]string) func() {
|
||||
return func() {
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil || d.Ready == nil {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
owner, repo, number, head := d.Owner(), d.Repo(), d.Number, d.Commit
|
||||
if sha := heads[id]; sha != "" {
|
||||
head = sha
|
||||
}
|
||||
return func() { h.mergeOne(id, heads) }
|
||||
}
|
||||
|
||||
// mergeOne asks the merge tool for one approved head and keeps its answer; whether it merged.
|
||||
func (h *Holder) mergeOne(id string, heads map[string]string) bool {
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil || d.Ready == nil {
|
||||
h.mu.Unlock()
|
||||
err := h.Forge.Merge(owner, repo, number, head, approvedHeads(heads))
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d = h.deliveries[id]
|
||||
if d == nil || d.Ready == nil {
|
||||
return
|
||||
}
|
||||
r, now := d.Ready, h.Now()
|
||||
r.merging = false
|
||||
if err == nil {
|
||||
// Issue 1025: the announcement is owed, kept with the delivery, so a death right after the merge
|
||||
// still says it once.
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: "merged", Line: string(mustJSON(mergedEvent(d, head))), Since: now})
|
||||
r.Merged, r.Answer, r.RefusedSince = &now, "merged", nil
|
||||
h.Logf("[mesh-delivery] %s merged on the operator's Approve at %s, its check passed against its trunk's head", id,
|
||||
shortOf(head, 12))
|
||||
h.keep(d)
|
||||
return
|
||||
}
|
||||
answer := oneLine(err.Error())
|
||||
if r.RefusedSince == nil {
|
||||
r.RefusedSince = &now
|
||||
}
|
||||
if answer != r.Answer {
|
||||
h.Logf("[mesh-delivery] %s is approved, and the merge tool did not merge it (asked again at the next verdict, Approve or composed check): %s", id,
|
||||
answer)
|
||||
}
|
||||
r.Answer = answer
|
||||
h.keep(d)
|
||||
return false
|
||||
}
|
||||
owner, repo, number, head := d.Owner(), d.Repo(), d.Number, d.Commit
|
||||
if sha := heads[id]; sha != "" {
|
||||
head = sha
|
||||
}
|
||||
h.mu.Unlock()
|
||||
err := h.Forge.Merge(owner, repo, number, head, approvedHeads(heads))
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d = h.deliveries[id]
|
||||
if d == nil || d.Ready == nil {
|
||||
return false
|
||||
}
|
||||
r, now := d.Ready, h.Now()
|
||||
r.merging = false
|
||||
if err == nil {
|
||||
// Issue 1025: the announcement is owed, kept with the delivery, so a death right after the merge
|
||||
// still says it once.
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: "merged", Line: string(mustJSON(mergedEvent(d, head))), Since: now})
|
||||
r.Merged, r.Answer, r.RefusedSince = &now, "merged", nil
|
||||
h.Logf("[mesh-delivery] %s merged on the operator's Approve at %s, its check passed against its trunk's head", id,
|
||||
shortOf(head, 12))
|
||||
h.keep(d)
|
||||
return true
|
||||
}
|
||||
answer := oneLine(err.Error())
|
||||
if r.RefusedSince == nil {
|
||||
r.RefusedSince = &now
|
||||
}
|
||||
if !outOfDateRefusal(answer) {
|
||||
r.Urgent = true // never a warning again for this head
|
||||
}
|
||||
if answer != r.Answer {
|
||||
h.Logf("[mesh-delivery] %s is approved, and the merge tool did not merge it (asked again at the next verdict, Approve or composed check): %s", id,
|
||||
answer)
|
||||
}
|
||||
r.Answer = answer
|
||||
h.keep(d)
|
||||
return false
|
||||
}
|
||||
|
||||
// approvedHeads are the heads an Approve covers as the merge tool is handed them: `owner/repo@<full sha>`, sorted.
|
||||
@@ -420,16 +437,19 @@ func (h *Holder) readyLines(now time.Time) []StalledLine {
|
||||
// again after each composed check. Said after the merge window, with the group and the tool's last answer.
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
||||
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: groupRefusedFor.String(),
|
||||
H2: "none: the group's composed check is asked again after each refusal, and the merge each minute — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d is approved at %s with its group %s closed (a sibling merged), and the merge tool has "+
|
||||
"refused it since %s: %s", d.Repository, d.Number, shortOf(r.Head, 12), d.Group,
|
||||
r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
|
||||
H2: "none: the group's composed check is asked again after each refusal, and the merge each minute — the operator's",
|
||||
Severity: refusalSeverity(r, now),
|
||||
Says: fmt.Sprintf("group %s is half merged: %s#%d is approved at %s and the merge tool has refused it since %s: %s. "+
|
||||
"Needed: the refusal's cause is met (the merge is asked again at each verdict, status or Approve), or the operator decides.",
|
||||
d.Group, d.Repository, d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
|
||||
case r.RefusedSince != nil && now.Sub(*r.RefusedSince) >= readyRefusedFor:
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
||||
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: readyRefusedFor.String(),
|
||||
H2: "none: the merge is asked again after each verdict, Approve or composed verdict, and each minute — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d is approved at %s, and the merge tool has refused it since %s: %s", d.Repository,
|
||||
d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
|
||||
H2: "none: the merge is asked again on a verdict, an Approve, a group's composed check or a merge check's " +
|
||||
"status turning success — the operator's",
|
||||
Says: fmt.Sprintf("%s%s#%d is approved at %s, and the merge tool has refused it since %s: %s%s", heldInGroup(d),
|
||||
d.Repository, d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer, neededOf(d)),
|
||||
Severity: severityOf(d, now)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -451,3 +471,58 @@ func mergedEvent(d *Delivery, head string) map[string]any {
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
// heldInGroup is the words that put a refused member in its group's pass: none for a lone delivery.
|
||||
func heldInGroup(d *Delivery) string {
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return "group " + d.Group + " is held, and no member after this one is merged: "
|
||||
}
|
||||
|
||||
// neededOf is what a refused member needs, for a member of a group (issue 1275).
|
||||
func neededOf(d *Delivery) string {
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return ". Needed: the refusal's cause is met (the merge is asked again at each verdict, status or Approve), or the operator decides."
|
||||
}
|
||||
|
||||
// outOfDateFor is how long a refusal for an out of date verdict is a warning before it is urgent (issue 1275, AC6).
|
||||
const outOfDateFor = 10 * time.Minute
|
||||
|
||||
// outOfDateRefusal is whether the merge tool refused because the member's verdict is judged against an earlier trunk: the
|
||||
// forge's "its verdict was judged against trunk <t>, and main is now <t>. The check of head <h> merged into trunk <t> was
|
||||
// asked" (gitea merge.ts), whose fresh check is already running.
|
||||
//
|
||||
// Words that also name another cause (a status not a success, missing or failed) are not this: the other cause is urgent
|
||||
// at once (ADR 0442, issue 1275 AC6).
|
||||
func outOfDateRefusal(answer string) bool {
|
||||
for _, other := range []string{"not a success", "(missing)", "(failure)", "(error)", "could not be asked"} {
|
||||
if strings.Contains(answer, other) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// gitea merge.ts: "Its check is already asked and not yet answered" (on its way), or "The check of head <h> merged into
|
||||
// trunk <t> was asked" (asked now). Its other refusals, among them "its check could not be asked", are not this.
|
||||
return strings.Contains(answer, "its verdict ") && strings.Contains(answer, " is now ") &&
|
||||
(strings.Contains(answer, "Its check is already asked and not yet answered") ||
|
||||
strings.Contains(answer, "merged into trunk") && strings.Contains(answer, "was asked"))
|
||||
}
|
||||
|
||||
// refusalSeverity is "warning" while a member's refusal for an out of date verdict is younger than ten minutes, as its
|
||||
// fresh check runs, and "urgent" for it after that and for every other refusal at once.
|
||||
func refusalSeverity(r *ReadyRun, now time.Time) string {
|
||||
if !r.Urgent && r.RefusedSince != nil && outOfDateRefusal(r.Answer) && now.Sub(*r.RefusedSince) < outOfDateFor {
|
||||
return "warning"
|
||||
}
|
||||
return "urgent"
|
||||
}
|
||||
|
||||
// severityOf is the severity of a refused member of a group, empty for a lone delivery.
|
||||
func severityOf(d *Delivery, now time.Time) string {
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return refusalSeverity(d.Ready, now)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC3: a refused merge is not asked again by any timer; only an event (here, mergeAgainNow called directly, as a
|
||||
// verdict, an Approve or a composed check does) asks it again.
|
||||
func TestAMergeRefusedIsSaidAtOnceAndAskedAgainOnlyOnAnEvent(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
@@ -93,6 +95,88 @@ func TestAMergeRefusedIsSaidAtOnceAndAskedAgainOnlyOnAnEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC1: a refused merge, approved and ready, is asked again at once when the forge's forge.status says one of
|
||||
// the merge checks (mesh/merge-gate, mesh/repo-check) is success for the commit the merge was refused at — the
|
||||
// delivery's approved head — and merges once the tool accepts (novox/hq mesh-catalog#366: the verdict reached this
|
||||
// owner before the forge wrote the statuses, the merge asked then was refused on a pending check, and nothing asked
|
||||
// it again when the statuses turned success).
|
||||
func TestAMergeRefusedIsAskedAgainWhenItsCheckTurnsSuccessOnTheForge(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check (pending)")
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("not asked once, refused: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.refuse = nil
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "app", SHA: readyHead, Context: "mesh/repo-check", State: "success"})
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("not asked again when its check turned success: %v", w.forge.merges)
|
||||
}
|
||||
if w.delivery(IDOf("novox/app", readyHead)).Ready.Merged == nil {
|
||||
t.Fatalf("it did not merge once the tool accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC2: a status of another context, of another commit, or a state other than success changes nothing for a
|
||||
// refused merge: it is not asked again.
|
||||
func TestAMergeRefusedIsNotAskedAgainOnAStatusThatDoesNotMatch(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check (pending)")
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("not asked once, refused: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.refuse = nil
|
||||
for _, e := range []ForgeStatusEvent{
|
||||
{Owner: "novox", Repo: "app", SHA: readyHead, Context: "some/other-check", State: "success"}, // another context
|
||||
{Owner: "novox", Repo: "app", SHA: pushed, Context: "mesh/repo-check", State: "success"}, // another commit
|
||||
{Owner: "novox", Repo: "app", SHA: readyHead, Context: "mesh/repo-check", State: "pending"}, // another state
|
||||
} {
|
||||
w.h.CommitStatus(e)
|
||||
w.settleAll()
|
||||
}
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("asked again on a status that should change nothing: %v", w.forge.merges)
|
||||
}
|
||||
if w.delivery(IDOf("novox/app", readyHead)).Ready.Merged != nil {
|
||||
t.Fatalf("merged on a status that should change nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC4: the stalled line for a refused approved head names the events that ask the merge again, and no longer
|
||||
// says "each minute" (removed by issue 1115; false since then — there is no timer).
|
||||
func TestTheStalledLineForARefusedApprovedHeadNamesTheEventsThatAskTheMergeAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check (pending)")
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("wanted one stalled line, got %+v", lines)
|
||||
}
|
||||
h2 := lines[0].H2
|
||||
if strings.Contains(h2, "each minute") {
|
||||
t.Fatalf("the stalled line still says 'each minute', false since issue 1115: %q", h2)
|
||||
}
|
||||
for _, want := range []string{"verdict", "Approve", "composed check", "status"} {
|
||||
if !strings.Contains(h2, want) {
|
||||
t.Fatalf("the stalled line does not name %q as asking the merge again: %q", want, h2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
|
||||
@@ -55,6 +55,7 @@ func TestAProposedDeliveryWhoseHeadHoldsAPassingVerdictAtTheForgeAdoptsItAndAsks
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC3: no timer asks a refused merge again; only an event does (here, mergeAgainNow called directly).
|
||||
func TestAMergeTheForgeRefusedIsAskedAgainOnlyOnAnEventWithNoTimer(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
|
||||
@@ -226,6 +226,8 @@ type StalledLine struct {
|
||||
Bound string `json:"bound"`
|
||||
H2 string `json:"h2"`
|
||||
Says string `json:"says"`
|
||||
// Severity is "urgent" for a group that is held or half merged (issue 1275); empty otherwise.
|
||||
Severity string `json:"severity,omitempty"`
|
||||
}
|
||||
|
||||
// Stalled is the `stalled` verb: what the controller's self-check raises as `delivery.<id>.stalled`.
|
||||
@@ -269,6 +271,7 @@ func (h *Holder) Stalled() []StalledLine {
|
||||
out = append(out, h.withChecks(h.uncheckedLines(now))...)
|
||||
out = append(out, h.orphanLines(now)...)
|
||||
out = append(out, h.readyLines(now)...)
|
||||
out = append(out, h.groupLines(now)...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -587,7 +587,8 @@ func TestFortyReservationsAtOnceFromTwoInstancesGetFortyDistinctConsecutiveNumbe
|
||||
}
|
||||
|
||||
// The carry over the bus (novox/hq ADR 0300 §6, ADR 0307): values the bucket kept before the switch are read
|
||||
// through the runtime's history, oldest first, and carried before the write that touches the issue.
|
||||
// through the runtime's history, oldest first, and carried before the write that touches the issue. The bucket
|
||||
// keeps the two values the manifest declares (novox/hq issue 1221), so of three written the last two are carried.
|
||||
func TestAWriteOverTheBusCarriesEveryValueTheBucketKeptFirst(t *testing.T) {
|
||||
b := newBus(t)
|
||||
var events []event
|
||||
@@ -617,7 +618,7 @@ func TestAWriteOverTheBusCarriesEveryValueTheBucketKeptFirst(t *testing.T) {
|
||||
titles = append(titles, op.Issue.Title)
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(ops) != "[carried carried carried noted]" || fmt.Sprint(titles) != "[first second third]" {
|
||||
if fmt.Sprint(ops) != "[carried carried noted]" || fmt.Sprint(titles) != "[second third]" {
|
||||
t.Fatalf("the log holds %v of %v", ops, titles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,8 @@ func TestTheSettingsStartTheCounterAndRegisterTheOwners(t *testing.T) {
|
||||
if !sort.StringsAreSorted(s.Owners) {
|
||||
t.Error("the owners are not sorted")
|
||||
}
|
||||
for _, repo := range []string{"hq", "mesh-catalog", "mesh-controller", "mesh-host", "mesh-sdk", "mesh-tools"} {
|
||||
// 1133/AC1: the repository mesh-delivery is registered, so an issue can be located in it.
|
||||
for _, repo := range []string{"hq", "mesh-catalog", "mesh-controller", "mesh-delivery", "mesh-host", "mesh-sdk", "mesh-tools"} {
|
||||
if Owners(s.Owners).owns(repo) != repo {
|
||||
t.Errorf("%s is not registered", repo)
|
||||
}
|
||||
@@ -178,3 +179,34 @@ func TestTheSettingsStartTheCounterAndRegisterTheOwners(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The owners the tracker runs with are the manifest's default (no layer sets them), and the template's copy is
|
||||
// what the test above reads: the two are one list, or that test proves a list nobody runs (novox/hq issue 1133).
|
||||
// 1133/AC2
|
||||
func TestTheOwnersDefaultIsTheTemplatesOwners(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Config struct {
|
||||
Owners struct {
|
||||
Default []string `json:"default"`
|
||||
} `json:"owners"`
|
||||
} `json:"config"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl, err := os.ReadFile(filepath.Join("..", "..", "files", "settings.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var s Settings
|
||||
if err := json.Unmarshal(tmpl, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Config.Owners.Default) == 0 || !reflect.DeepEqual(m.Config.Owners.Default, s.Owners) {
|
||||
t.Fatalf("the owners default (%d) and the template's owners (%d) differ", len(m.Config.Owners.Default), len(s.Owners))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"mesh-controller",
|
||||
"mesh-controller modules/build-agent",
|
||||
"mesh-controller modules/route-proxy",
|
||||
"mesh-delivery",
|
||||
"mesh-host",
|
||||
"mesh-lab",
|
||||
"mesh-media-catalog",
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"state": [
|
||||
{
|
||||
"name": "issues",
|
||||
"history": 8
|
||||
"history": 2
|
||||
},
|
||||
"counters",
|
||||
"grooming",
|
||||
@@ -221,6 +221,7 @@
|
||||
"mesh-controller",
|
||||
"mesh-controller modules/build-agent",
|
||||
"mesh-controller modules/route-proxy",
|
||||
"mesh-delivery",
|
||||
"mesh-host",
|
||||
"mesh-lab",
|
||||
"mesh-media-catalog",
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
//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 (
|
||||
"encoding/json"
|
||||
"html"
|
||||
"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
|
||||
}
|
||||
|
||||
// expectSignIn drives one of the three requests RequireSession protects (a page, an attachment, a send) with
|
||||
// no session cookie, answering the end of the road: a GET follows RequireSession's own one-time bounce first
|
||||
// (review round 1 finding 1 — the first cookieless answer is a 200 page of this origin's own, never a
|
||||
// redirect, so this reads its own meta refresh and asks it, exactly as a browser's own would), the same as
|
||||
// followSignInBounce; a POST (a bounce would lose its own body) is sent to sign in directly, unchanged.
|
||||
func expectSignIn(t *testing.T, mw *startedMailWeb, method, path string) *http.Response {
|
||||
t.Helper()
|
||||
if method == http.MethodGet {
|
||||
return followSignInBounce(t, mw, path)
|
||||
}
|
||||
return doWithCookie(t, mw, method, path, nil, nil)
|
||||
}
|
||||
|
||||
// extractOlderLink reads the folder page's own "Older" link out of its rendered HTML, by its own anchor text
|
||||
// — never built by hand (review round 1 finding 3): html/template's own escaping of its href (percent-encoding
|
||||
// the date's own colons, "&" for the query's own separators) is exactly what a browser's own link carries,
|
||||
// and what this reads back.
|
||||
func extractOlderLink(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
marker := ">Older</a>"
|
||||
end := strings.Index(body, marker)
|
||||
if end < 0 {
|
||||
t.Fatalf("no Older link in the page:\n%s", body)
|
||||
}
|
||||
head := body[:end]
|
||||
hrefStart := strings.LastIndex(head, `href="`)
|
||||
if hrefStart < 0 {
|
||||
t.Fatalf("the Older link carries no href:\n%s", body)
|
||||
}
|
||||
href := strings.TrimSuffix(head[hrefStart+len(`href="`):], `"`)
|
||||
return html.UnescapeString(href)
|
||||
}
|
||||
|
||||
// 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 := expectSignIn(t, mw, http.MethodGet, path)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("%s with no session: status %d, want 302 (redirect to sign in, after RequireSession's own bounce)", 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.
|
||||
resp := followSignInBounce(t, mw, "/")
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("after the bounce: status %d, want 302 to sign in", resp.StatusCode)
|
||||
}
|
||||
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 (review round 1 finding 3: read the
|
||||
// "Older" link out of the page and follow it, rather than build page 2's own URL by hand — and compare the
|
||||
// 50th mail's own date exactly, not merely up to its hour, which every one of these 55 mails shares).
|
||||
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)
|
||||
olderLink := extractOlderLink(t, string(page1Body))
|
||||
olderQuery, err := url.ParseQuery(strings.SplitN(olderLink, "?", 2)[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if olderQuery.Get("before") != fiftieth {
|
||||
t.Fatalf("the folder's first page links to before=%q, want exactly the 50th mail's own date %q", olderQuery.Get("before"), fiftieth)
|
||||
}
|
||||
|
||||
// The folder's second page: followed from that same link, not rebuilt.
|
||||
page2 := doWithCookie(t, mw, http.MethodGet, olderLink, 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)
|
||||
}
|
||||
// Exact subjects (">mail 5</a>", never a substring "mail 55" would also satisfy): the second page shows the
|
||||
// 55th-51st mails' own oldest five, and none of the newer ones the first page already showed.
|
||||
for _, subject := range []string{"mail 5", "mail 4", "mail 3", "mail 2", "mail 1"} {
|
||||
if !strings.Contains(string(page2Body), ">"+subject+"</a>") {
|
||||
t.Fatalf("the folder's second page does not show %q (its own exact subject):\n%s", subject, page2Body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(page2Body), ">mail 6</a>") {
|
||||
t.Fatalf("the folder's second page repeats mail 6, which belongs on the first page:\n%s", page2Body)
|
||||
}
|
||||
|
||||
// The second folder page's own mail-store.search was asked before=the 50th mail's own date, not merely
|
||||
// trusted (finding 3).
|
||||
var searchCalls []askRecord
|
||||
for _, c := range mw.store.Calls() {
|
||||
if c.Key == "seat:mail-store.search" {
|
||||
searchCalls = append(searchCalls, c)
|
||||
}
|
||||
}
|
||||
if len(searchCalls) != 2 {
|
||||
t.Fatalf("mail-store.search was asked %d times, want exactly 2 (one per folder page)", len(searchCalls))
|
||||
}
|
||||
var secondSearchArgs map[string]any
|
||||
if err := json.Unmarshal(searchCalls[1].Body, &secondSearchArgs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secondSearchArgs["before"] != fiftieth {
|
||||
t.Fatalf("the second folder page's own search asked before=%v, want the 50th mail's own date %q", secondSearchArgs["before"], fiftieth)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Follow the send's own meta refresh (review round 1 finding 3), never ask /send-status by hand: this both
|
||||
// exercises the real link a browser would follow and proves it names the right send (send-1, mail-store's
|
||||
// own answer to mail_send).
|
||||
statusURL := extractMetaRefreshURL(t, string(sendBody))
|
||||
if statusURL != "/send-status?id=send-1" {
|
||||
t.Fatalf("the send's own refresh: %q, want /send-status?id=send-1", statusURL)
|
||||
}
|
||||
statusResp := doWithCookie(t, mw, http.MethodGet, statusURL, 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)
|
||||
}
|
||||
// The mail page's own CSP, and every other answer's (review round 1 finding 3): doWithCookie and doForm
|
||||
// check it on every response they return, not only the ones a test asks for by hand.
|
||||
|
||||
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 string(attBody) != "hello world" {
|
||||
t.Fatalf("attachment body: %q", attBody)
|
||||
}
|
||||
|
||||
// A cookieless GET still carries the CSP on its own bounce (checked inside doWithCookie too); and a
|
||||
// request for a really different Host (review round 1 finding 3 — the step here previously asked the
|
||||
// internal host by mistake, doWithCookie always setting it) is answered 404, and carries the CSP too
|
||||
// (issue 1283/AC2).
|
||||
bounceResp := doWithCookie(t, mw, http.MethodGet, "/", nil, nil)
|
||||
_ = bounceResp.Body.Close()
|
||||
if bounceResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("a cookieless GET: status %d, want 200 (RequireSession's own one-time bounce)", bounceResp.StatusCode)
|
||||
}
|
||||
|
||||
otherHostReq, err := http.NewRequest(http.MethodGet, "http://"+mw.internalHost+"/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherHostReq.Host = "example.com"
|
||||
otherHostResp, err := mw.client.Do(otherHostReq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer otherHostResp.Body.Close()
|
||||
if otherHostResp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("Host example.com: status %d, want 404", otherHostResp.StatusCode)
|
||||
}
|
||||
if got := otherHostResp.Header.Get("Content-Security-Policy"); got != CSP {
|
||||
t.Fatalf("Host example.com's own 404: CSP %q, want %q", got, CSP)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
// The accounts and folder pages (novox/hq ADR 0440 §4, issue 1264): the accounts page lists every account
|
||||
// mail-store.accounts answers, each with its state and its folders' mail and unread counts; the folder page
|
||||
// lists one folder's mails, newest first, 50 a page, from mail-store.search, paged by the date of the last one
|
||||
// shown. Every page behind the sign-in (NewServer's own RequireSession wrapping); rendered only through
|
||||
// html/template, so nothing a mail carries is ever interpreted as markup.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// accountBucketValue and accountBucketFolder mirror mesh-mail's own AccountValue and AccountFolderValue
|
||||
// (modules/mesh-mail/cmd/mesh-mail/mirror.go, issue 1255) — copied, not imported: mesh-mail-web is its own Go
|
||||
// module, and the shape is mesh-mail's own to change. Only the fields this page reads are kept.
|
||||
type accountBucketValue struct {
|
||||
State string `json:"state"`
|
||||
Folders []accountBucketFolder `json:"folders"`
|
||||
}
|
||||
|
||||
type accountBucketFolder struct {
|
||||
Name string `json:"name"`
|
||||
Mails int `json:"mails"`
|
||||
Unread int `json:"unread"`
|
||||
}
|
||||
|
||||
// accountsAnswer is mail-store.accounts' own answer (mesh-mail tools.go accountsTool), the fields this page
|
||||
// reads.
|
||||
type accountsAnswer struct {
|
||||
Accounts []struct {
|
||||
Account string `json:"account"`
|
||||
Kind string `json:"kind"`
|
||||
Address string `json:"address"`
|
||||
State string `json:"state"`
|
||||
Why string `json:"why"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
|
||||
// accountRow is one account's own line on the accounts page.
|
||||
type accountRow struct {
|
||||
Name, Kind, Address, State, Why string
|
||||
FoldersKnown bool
|
||||
Folders []accountBucketFolder
|
||||
}
|
||||
|
||||
var accountsTemplate = template.Must(template.New("accounts").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>Mail accounts</title></head><body>
|
||||
<h1>Accounts</h1>
|
||||
{{if .Error}}<p>{{.Error}}</p>{{else}}
|
||||
<ul>
|
||||
{{range .Accounts}}
|
||||
{{$row := .}}
|
||||
<li><strong>{{.Name}}</strong> ({{.Kind}}, {{.Address}}) — {{.State}}{{if .Why}}: {{.Why}}{{end}}
|
||||
{{if .FoldersKnown}}
|
||||
<ul>
|
||||
{{range .Folders}}<li><a href="/folder?account={{$row.CurrentAccount}}&folder={{.Name}}">{{.Name}}</a>: {{.Mails}} mail(s), {{.Unread}} unread</li>{{end}}
|
||||
</ul>
|
||||
{{else}}<p>its folders are not known yet</p>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</body></html>`))
|
||||
|
||||
// accountsTemplateData avoids the html/template range variable losing access to the account's own name inside
|
||||
// its nested range over folders (Go templates have no closures): each row carries its own name twice.
|
||||
type accountsTemplateData struct {
|
||||
Accounts []accountRowWithName
|
||||
Error string
|
||||
}
|
||||
|
||||
type accountRowWithName struct {
|
||||
accountRow
|
||||
CurrentAccount string
|
||||
}
|
||||
|
||||
// accountsPage is issue 1264/AC1-AC5: lists every account mail-store.accounts answers (never one it did not,
|
||||
// AC2), each with its folders' mail and unread counts read from mesh-mail's own account bucket — one Get per
|
||||
// account mail-store answered, never Keys() (AC2), and never failing the whole page when an account has no
|
||||
// key there yet (AC4). The only seats this page calls are mail-store.accounts and (the folder page, below)
|
||||
// mail-store.search (AC5).
|
||||
func (b *Bundle) accountsPage(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.accountsAsk(ctx)
|
||||
if err != nil {
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{Error: "mail-store does not answer; try again shortly"})
|
||||
return
|
||||
}
|
||||
var answer accountsAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{Error: "mail-store's answer could not be read"})
|
||||
return
|
||||
}
|
||||
rows := make([]accountRowWithName, 0, len(answer.Accounts))
|
||||
for _, a := range answer.Accounts {
|
||||
row := accountRow{Name: a.Account, Kind: a.Kind, Address: a.Address, State: a.State, Why: a.Why}
|
||||
// 1264/AC2, AC4: read only this account's own key, never the bucket's whole list; a missing key (the
|
||||
// account's keeper has not mirrored it yet) is not an error, just "not known yet".
|
||||
entry, err := b.accountBucketGet(a.Account)
|
||||
if err == nil && entry != nil {
|
||||
var v accountBucketValue
|
||||
if json.Unmarshal(entry.Value, &v) == nil {
|
||||
row.Folders = v.Folders
|
||||
row.FoldersKnown = true
|
||||
}
|
||||
}
|
||||
rows = append(rows, accountRowWithName{accountRow: row, CurrentAccount: a.Account})
|
||||
}
|
||||
renderPage(w, accountsTemplate, accountsTemplateData{Accounts: rows})
|
||||
}
|
||||
|
||||
// MostFolderResults is the folder and search pages' own page size (novox/hq ADR 0440 §4, issue 1264/AC3).
|
||||
const MostFolderResults = 50
|
||||
|
||||
// searchResult is one mail as mail-store.search answers it (mesh-mail imap.go's metadata), the fields the
|
||||
// folder and search pages read.
|
||||
type searchResult struct {
|
||||
Account string `json:"account"`
|
||||
Folder string `json:"folder"`
|
||||
UID int `json:"uid"`
|
||||
Date string `json:"date"`
|
||||
From []personResult `json:"from"`
|
||||
To []personResult `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Size int64 `json:"size"`
|
||||
Flags []string `json:"flags"`
|
||||
}
|
||||
|
||||
type personResult struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// searchAnswer is mail-store.search's own answer shape for a plain (non group-by-sender) search.
|
||||
type searchAnswer struct {
|
||||
Found []searchResult `json:"found"`
|
||||
}
|
||||
|
||||
var folderTemplate = template.Must(template.New("folder").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>{{.Folder}}</title></head><body>
|
||||
<h1>{{.Account}} — {{.Folder}}</h1>
|
||||
{{if .Error}}<p>{{.Error}}</p>{{else}}
|
||||
<ul>
|
||||
{{range .Mails}}<li>{{.Date}} — {{range .From}}{{.Name}} <{{.Address}}> {{end}}— <a href="/mail?account={{.Account}}&folder={{.Folder}}&uid={{.UID}}">{{.Subject}}</a></li>{{end}}
|
||||
</ul>
|
||||
{{if .NextBefore}}<p><a href="/folder?account={{.Account}}&folder={{.Folder}}&before={{.NextBefore}}">Older</a></p>{{end}}
|
||||
{{end}}
|
||||
</body></html>`))
|
||||
|
||||
type folderTemplateData struct {
|
||||
Account, Folder, NextBefore, Error string
|
||||
Mails []searchResult
|
||||
}
|
||||
|
||||
// folderPage is issue 1264/AC3: the 50 newest mails of one folder, newest first, from mail-store.search; the
|
||||
// next link asks for mails before the date of the 50th shown. A page with fewer than 50 has no next link: a
|
||||
// mail sharing that exact second with the last one shown could be skipped by the next page (a known limit of
|
||||
// paging by date alone, accepted rather than fixed here).
|
||||
func (b *Bundle) folderPage(w http.ResponseWriter, r *http.Request) {
|
||||
account := r.URL.Query().Get("account")
|
||||
folder := r.URL.Query().Get("folder")
|
||||
before := r.URL.Query().Get("before")
|
||||
if account == "" || folder == "" {
|
||||
http.Error(w, "account and folder are both needed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
args := map[string]any{"account": account, "folder": folder, "limit": float64(MostFolderResults)}
|
||||
if before != "" {
|
||||
args["before"] = before
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.searchAsk(ctx, args)
|
||||
if err != nil {
|
||||
renderPage(w, folderTemplate, folderTemplateData{Account: account, Folder: folder, Error: "the folder could not be answered"})
|
||||
return
|
||||
}
|
||||
var answer searchAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
renderPage(w, folderTemplate, folderTemplateData{Account: account, Folder: folder, Error: "the answer could not be read"})
|
||||
return
|
||||
}
|
||||
data := folderTemplateData{Account: account, Folder: folder, Mails: answer.Found}
|
||||
if len(answer.Found) >= MostFolderResults {
|
||||
data.NextBefore = answer.Found[len(answer.Found)-1].Date
|
||||
}
|
||||
renderPage(w, folderTemplate, data)
|
||||
}
|
||||
|
||||
// renderPage executes tmpl, setting the page's own content type first; a template that fails to execute (it
|
||||
// never should, given these fixed templates) says so in words, never a stack trace.
|
||||
func renderPage(w http.ResponseWriter, tmpl *template.Template, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
fmt.Fprintf(w, "\n<!-- the page could not be rendered whole: %v -->", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
// The accounts and folder pages (novox/hq ADR 0440 §4, issue 1264).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// fakeAccountsBucket is a fake AccountsBucketGet (tools.go): a map, and a counter of every Get call so a test
|
||||
// can prove which keys were read.
|
||||
type fakeAccountsBucket struct {
|
||||
values map[string]accountBucketValue
|
||||
gets []string
|
||||
}
|
||||
|
||||
func (f *fakeAccountsBucket) get(account string) (*stdio.StateEntry, error) {
|
||||
f.gets = append(f.gets, account)
|
||||
v, ok := f.values[account]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
raw, _ := json.Marshal(v)
|
||||
return &stdio.StateEntry{Key: account, Value: raw}, nil
|
||||
}
|
||||
|
||||
func accountsAnswerJSON(accounts ...map[string]any) ([]byte, error) {
|
||||
return json.Marshal(map[string]any{"accounts": accounts})
|
||||
}
|
||||
|
||||
// 1264/AC1: mail-store answers accounts a and b, both held in the bucket; the page lists both, each with its
|
||||
// state and its folders' mail and unread counts.
|
||||
func TestAccountsPageListsEachAccountWithItsStateAndFolderCounts(t *testing.T) {
|
||||
raw, err := 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": "syncing"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{
|
||||
"a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 10, Unread: 2}}},
|
||||
"b": {State: "syncing", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 3, Unread: 0}}},
|
||||
}}
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
|
||||
AccountBucket: bucket.get,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
b.accountsPage(w, r)
|
||||
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"a", "current", "INBOX", "10 mail(s)", "2 unread", "b", "syncing", "3 mail(s)", "0 unread"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("the page is missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1264/AC2: the bucket also holds account c, which mail-store did not answer; nothing of c appears, and its
|
||||
// key is never read.
|
||||
func TestAccountsPageNeverShowsOrReadsAnAccountMailStoreDidNotAnswer(t *testing.T) {
|
||||
raw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{
|
||||
"a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 1, Unread: 0}}},
|
||||
"c": {State: "current", Folders: []accountBucketFolder{{Name: "Secret", Mails: 99, Unread: 99}}},
|
||||
}}
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
|
||||
AccountBucket: bucket.get,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
b.accountsPage(w, r)
|
||||
|
||||
if strings.Contains(w.Body.String(), "c") && strings.Contains(w.Body.String(), "Secret") {
|
||||
t.Fatalf("the page names account c, which mail-store did not answer:\n%s", w.Body.String())
|
||||
}
|
||||
for _, g := range bucket.gets {
|
||||
if g == "c" {
|
||||
t.Fatalf("account c's key was read, though mail-store did not answer it: %v", bucket.gets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1264/AC3: a folder with 120 mails shows the 50 newest, newest first, and its next link asks mail-store.search
|
||||
// for that folder with before the date of the 50th shown.
|
||||
func TestFolderPageShows50NewestAndLinksToTheNextPageByDate(t *testing.T) {
|
||||
var found []map[string]any
|
||||
for i := 0; i < 50; i++ {
|
||||
found = append(found, map[string]any{
|
||||
"account": "mesh", "folder": "INBOX", "uid": 120 - i, "subject": "s",
|
||||
"date": "2026-01-01T12:00:00Z", "from": []map[string]any{{"address": "x@example.org"}},
|
||||
})
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"found": found})
|
||||
var gotArgs map[string]any
|
||||
b := &Bundle{Search: func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
gotArgs = args
|
||||
return raw, nil
|
||||
}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/folder?account=mesh&folder=INBOX", nil)
|
||||
b.folderPage(w, r)
|
||||
|
||||
if gotArgs["account"] != "mesh" || gotArgs["folder"] != "INBOX" || gotArgs["limit"] != float64(50) {
|
||||
t.Fatalf("mail-store.search called with %v", gotArgs)
|
||||
}
|
||||
body := strings.ToLower(w.Body.String())
|
||||
if !strings.Contains(body, "before=2026-01-01t12") {
|
||||
t.Fatalf("no next link with the 50th mail's date:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1264/AC4: an account mail-store answered with no key in the bucket yet is listed, its folders said not known
|
||||
// yet, and the page does not fail.
|
||||
func TestAccountsPageSaysFoldersNotKnownYetWithoutFailing(t *testing.T) {
|
||||
raw, err := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "unknown"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := &fakeAccountsBucket{values: map[string]accountBucketValue{}}
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
|
||||
AccountBucket: bucket.get,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
b.accountsPage(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "not known yet") {
|
||||
t.Fatalf("the page does not say folders are not known yet:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1264/AC5: across the accounts and folder pages, the only verbs called are mail-store.accounts and
|
||||
// mail-store.search.
|
||||
func TestAccountsAndFolderPagesCallOnlyAccountsAndSearch(t *testing.T) {
|
||||
var accountsCalled, searchCalled int
|
||||
raw, _ := accountsAnswerJSON(map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"})
|
||||
b := &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { accountsCalled++; return raw, nil },
|
||||
Search: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
searchCalled++
|
||||
return json.Marshal(map[string]any{"found": []any{}})
|
||||
},
|
||||
AccountBucket: (&fakeAccountsBucket{values: map[string]accountBucketValue{}}).get,
|
||||
Read: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
t.Fatal("mail-store.read called")
|
||||
return nil, nil
|
||||
},
|
||||
Send: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
t.Fatal("mail-store.send called")
|
||||
return nil, nil
|
||||
},
|
||||
SendStatus: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
t.Fatal("mail-store.send-status called")
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
b.accountsPage(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
b.folderPage(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/folder?account=a&folder=INBOX", nil))
|
||||
|
||||
if accountsCalled != 1 || searchCalled != 1 {
|
||||
t.Fatalf("accounts called %d time(s), search %d time(s)", accountsCalled, searchCalled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package main
|
||||
|
||||
// Keycloak sign-in (novox/hq ADR 0440 §3, issue 1263): the authorisation-code flow with state and PKCE against
|
||||
// the bound issuer, the callback at /signed-in, the ID token verified (issuer, audience, expiry, signature from
|
||||
// the issuer's JWKS), the identity (its preferred_username) checked against the trusted setting operators, and
|
||||
// a session kept in memory only, its cookie Secure, HttpOnly, SameSite=Strict, ending SessionLifetime after
|
||||
// sign-in. Library choice, said as the brief asks: golang.org/x/oauth2 for the code exchange and
|
||||
// github.com/coreos/go-oidc/v3 for the ID token's verification (issuer, audience, expiry and signature against
|
||||
// the issuer's own JWKS) — chosen over hand-rolling JWT checks here, since go-oidc's verifier is the
|
||||
// already-reviewed, standard implementation of exactly what ADR 0440 §3 asks for.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// SignedInPath is the callback the issuer redirects back to (the oidc-client contribution's own callback,
|
||||
// module.json).
|
||||
const SignedInPath = "/signed-in"
|
||||
|
||||
// OIDCConfig is the oidc-client binding's own facts this module reads (issue 1262's oidc.json).
|
||||
type OIDCConfig struct {
|
||||
ClientID string `json:"client-id"`
|
||||
ClientSecret string `json:"client-secret"`
|
||||
Issuer string `json:"issuer"`
|
||||
}
|
||||
|
||||
// ReadOIDCConfig reads the rendered oidc-client binding.
|
||||
func ReadOIDCConfig(path string) (OIDCConfig, error) {
|
||||
if path == "" {
|
||||
return OIDCConfig{}, errors.New("the module was started without its oidc setting (MESH_MAIL_WEB_OIDC)")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return OIDCConfig{}, fmt.Errorf("the oidc setting cannot be read: %w", err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var c OIDCConfig
|
||||
if err := dec.Decode(&c); err != nil {
|
||||
return OIDCConfig{}, fmt.Errorf("the oidc setting is not the module's: %w", err)
|
||||
}
|
||||
if c.ClientID == "" || c.Issuer == "" {
|
||||
return OIDCConfig{}, errors.New("the oidc setting names no client id or issuer")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Authenticator is how this module reaches the issuer: build the redirect to it, exchange a code for tokens,
|
||||
// and verify the ID token it names.
|
||||
type Authenticator struct {
|
||||
OAuth2 oauth2.Config
|
||||
Verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// NewAuthenticator discovers the issuer (its authorisation, token and JWKS endpoints, novox/hq issue 1263) and
|
||||
// builds an Authenticator for it. redirectURL is this module's own callback, https://<internal host>/signed-in.
|
||||
func NewAuthenticator(ctx context.Context, cfg OIDCConfig, redirectURL string) (*Authenticator, error) {
|
||||
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the issuer %s could not be reached: %w", cfg.Issuer, err)
|
||||
}
|
||||
return &Authenticator{
|
||||
OAuth2: oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile"},
|
||||
},
|
||||
Verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BounceParam marks a request that already spent RequireSession's own one-time bounce (review round 1 of
|
||||
// issue 1263, finding 1): a browser arriving with no session cookie at all may simply have none yet, or may
|
||||
// hold a live one that this particular navigation does not carry — exactly what a cookie marked
|
||||
// SameSite=Strict looks like on a navigation this browser does not count as same-site, which both Keycloak's
|
||||
// own redirect to the callback and a link clicked from another site (Telegram) are, even across an otherwise
|
||||
// live session. A page this module's own origin serves, whose own follow-on navigation (a meta refresh, not an
|
||||
// HTTP redirect) is same-site, carries the cookie if there is one; a second cookieless arrival, marked by this
|
||||
// parameter, means there truly is none, and goes to sign-in instead. Stripped from the return URL a sign-in
|
||||
// keeps (returnURL below), so it never leaks into the page the browser ends up on.
|
||||
const BounceParam = "mww-bounced"
|
||||
|
||||
// bounceTemplate is both RequireSession's own one-time bounce and Callback's own return to the page first
|
||||
// asked: the same shape, a 200 page this origin serves whose own navigation carries a Strict cookie that a
|
||||
// redirect begun elsewhere (Keycloak's 302 to this callback) would not.
|
||||
var bounceTemplate = template.Must(template.New("bounce").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>Mail</title><meta http-equiv="refresh" content="0; url={{.}}"></head>
|
||||
<body><p><a href="{{.}}">Continue</a></p></body></html>`))
|
||||
|
||||
// renderBounce answers a 200 page whose own navigation goes to url, next (RequireSession's bounce, or
|
||||
// Callback's own return once signed in).
|
||||
func renderBounce(w http.ResponseWriter, url string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = bounceTemplate.Execute(w, url)
|
||||
}
|
||||
|
||||
// returnURL is the page a sign-in should return to once it succeeds, or RequireSession's own bounce should
|
||||
// repeat: r's own request URI, with BounceParam stripped (it is this module's own bookkeeping, never the page
|
||||
// the browser should end up on).
|
||||
func returnURL(r *http.Request) string {
|
||||
u := *r.URL
|
||||
if u.RawQuery != "" {
|
||||
q := u.Query()
|
||||
q.Del(BounceParam)
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
return u.RequestURI()
|
||||
}
|
||||
|
||||
// bouncedURL is r's own request URI with BounceParam added, so a second cookieless arrival at the same page is
|
||||
// told apart from the first.
|
||||
func bouncedURL(r *http.Request) string {
|
||||
u := *r.URL
|
||||
q := u.Query()
|
||||
q.Set(BounceParam, "1")
|
||||
u.RawQuery = q.Encode()
|
||||
return u.RequestURI()
|
||||
}
|
||||
|
||||
// Auth wires sign-in into a Server: RequireSession protects a page, an attachment or a send (issue 1263/AC1),
|
||||
// and Callback is /signed-in.
|
||||
type Auth struct {
|
||||
Auth *Authenticator
|
||||
Sessions *Sessions
|
||||
Pending *PendingAuths
|
||||
Operators Operators
|
||||
Logf func(format string, a ...any)
|
||||
}
|
||||
|
||||
func (a *Auth) logf(format string, args ...any) {
|
||||
if a.Logf != nil {
|
||||
a.Logf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// sessionKey is the context key RequireSession hands the signed-in Session on to the handler it wraps.
|
||||
type sessionKey struct{}
|
||||
|
||||
// SessionFrom is the session a handler wrapped by RequireSession was asked with.
|
||||
func SessionFrom(r *http.Request) (Session, bool) {
|
||||
s, ok := r.Context().Value(sessionKey{}).(Session)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// RequireSession is issue 1263/AC1, AC5: no session, or one that ended 12 hours ago, is answered a redirect to
|
||||
// the issuer's authorisation endpoint (state, PKCE challenge, this module's own callback) rather than the page,
|
||||
// attachment or send asked for; a live session is handed on to next.
|
||||
//
|
||||
// A GET that carries no session cookie at all (review round 1 finding 1) is bounced once through this origin's
|
||||
// own page first, rather than sent to sign in at once: a cookie marked SameSite=Strict is withheld from
|
||||
// exactly this shape of request when the live session's own cookie would otherwise have been there (Keycloak's
|
||||
// redirect to the callback, a link from another site), and this origin's own follow-on navigation is where it
|
||||
// reappears. A cookie that did reach this request but names no live session (it expired, or never existed) is
|
||||
// not that case — the browser did carry it — so it goes to sign in directly, as does any non-GET (a bounce
|
||||
// would lose its body) and a second cookieless GET already marked by BounceParam.
|
||||
func (a *Auth) RequireSession(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(SessionCookie)
|
||||
if err == nil {
|
||||
if sess, ok := a.Sessions.Get(cookie.Value); ok {
|
||||
ctx := context.WithValue(r.Context(), sessionKey{}, sess)
|
||||
next(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
a.redirectToSignIn(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Query().Get(BounceParam) == "" {
|
||||
renderBounce(w, bouncedURL(r))
|
||||
return
|
||||
}
|
||||
a.redirectToSignIn(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Auth) redirectToSignIn(w http.ResponseWriter, r *http.Request) {
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
state, err := a.Pending.Create(verifier, returnURL(r))
|
||||
if err != nil {
|
||||
http.Error(w, "sign-in could not be started", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
url := a.Auth.OAuth2.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
}
|
||||
|
||||
// Callback is /signed-in (issue 1263/AC2, AC3, AC4): a state this module did not issue, an exchange that
|
||||
// fails, an ID token of another audience, signed by another key, or expired, or an identity operators does not
|
||||
// name, is answered 403 and makes no session. Only once the ID token verifies and names an operator is a
|
||||
// session made, its cookie set, and the browser sent back to the page it first asked for — through a 200 page
|
||||
// of this origin's own, not a 302 (review round 1 finding 1): the navigation that landed here started at
|
||||
// Keycloak, so a redirect onward from it is still, in the browser's own eyes, a navigation Keycloak began, and
|
||||
// a cookie marked SameSite=Strict (just set by this very answer) is not sent on it or on anything it redirects
|
||||
// to in turn. A follow-on navigation this page itself starts has no such history, so the cookie is sent on it.
|
||||
func (a *Auth) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
refuse := func(why string) {
|
||||
a.logf("[mesh-mail-web] sign-in refused: %s", why)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
}
|
||||
query := r.URL.Query()
|
||||
if errDesc := query.Get("error"); errDesc != "" {
|
||||
refuse("the issuer answered " + errDesc)
|
||||
return
|
||||
}
|
||||
pending, ok := a.Pending.Take(query.Get("state"))
|
||||
if !ok {
|
||||
refuse("the state is not one this module issued, or it expired")
|
||||
return
|
||||
}
|
||||
code := query.Get("code")
|
||||
if code == "" {
|
||||
refuse("the callback carries no code")
|
||||
return
|
||||
}
|
||||
tok, err := a.Auth.OAuth2.Exchange(r.Context(), code, oauth2.VerifierOption(pending.Verifier))
|
||||
if err != nil {
|
||||
refuse(fmt.Sprintf("the code could not be exchanged: %v", err))
|
||||
return
|
||||
}
|
||||
rawIDToken, ok := tok.Extra("id_token").(string)
|
||||
if !ok || rawIDToken == "" {
|
||||
refuse("the issuer's answer carries no ID token")
|
||||
return
|
||||
}
|
||||
idToken, err := a.Auth.Verifier.Verify(r.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
// Issuer, audience, expiry and signature are all checked here, by the verifier (1263/AC4).
|
||||
refuse(fmt.Sprintf("the ID token does not verify: %v", err))
|
||||
return
|
||||
}
|
||||
var claims struct {
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
refuse(fmt.Sprintf("the ID token's claims could not be read: %v", err))
|
||||
return
|
||||
}
|
||||
allowed, why := a.Operators.Allows(claims.PreferredUsername)
|
||||
if !allowed {
|
||||
refuse(why)
|
||||
return
|
||||
}
|
||||
id, err := a.Sessions.Create(claims.PreferredUsername)
|
||||
if err != nil {
|
||||
refuse(fmt.Sprintf("the session could not be made: %v", err))
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookie,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(SessionLifetime / time.Second),
|
||||
})
|
||||
returnTo := pending.Return
|
||||
if returnTo == "" {
|
||||
returnTo = "/"
|
||||
}
|
||||
renderBounce(w, returnTo)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package main
|
||||
|
||||
// Keycloak sign-in (novox/hq ADR 0440 §3, issue 1263): an httptest stand-in issuer, as the lead's prepare
|
||||
// findings said to check this against, minting its own ID tokens (RS256) so a test controls exactly what
|
||||
// go-oidc's verifier is asked to check: the right issuer, audience, expiry and signature, and the wrong ones.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeIssuer is a minimal OIDC issuer: discovery, JWKS and a token endpoint that answers whatever ID token the
|
||||
// test set up, regardless of the code it is asked to exchange (the code's own validity is not what issue
|
||||
// 1263's acceptance criteria are about; the ID token's is).
|
||||
type fakeIssuer struct {
|
||||
*httptest.Server
|
||||
key *rsa.PrivateKey
|
||||
kid string
|
||||
nextIDToken string
|
||||
}
|
||||
|
||||
func newFakeIssuer(t *testing.T) *fakeIssuer {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi := &fakeIssuer{key: key, kid: "test-key"}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", fi.discovery)
|
||||
mux.HandleFunc("/jwks", fi.jwks)
|
||||
mux.HandleFunc("/token", fi.token)
|
||||
fi.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(fi.Server.Close)
|
||||
return fi
|
||||
}
|
||||
|
||||
func (fi *fakeIssuer) discovery(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": fi.Server.URL,
|
||||
"authorization_endpoint": fi.Server.URL + "/authorize",
|
||||
"token_endpoint": fi.Server.URL + "/token",
|
||||
"jwks_uri": fi.Server.URL + "/jwks",
|
||||
"userinfo_endpoint": fi.Server.URL + "/userinfo",
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
})
|
||||
}
|
||||
|
||||
func (fi *fakeIssuer) jwks(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
n := base64.RawURLEncoding.EncodeToString(fi.key.PublicKey.N.Bytes())
|
||||
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(fi.key.PublicKey.E)).Bytes())
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]any{
|
||||
{"kty": "RSA", "alg": "RS256", "use": "sig", "kid": fi.kid, "n": n, "e": e},
|
||||
}})
|
||||
}
|
||||
|
||||
func (fi *fakeIssuer) token(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "fake-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"id_token": fi.nextIDToken,
|
||||
})
|
||||
}
|
||||
|
||||
func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
// sign mints an RS256 JWT of claims, signed with key under kid — the real issuer's own key unless a test asks
|
||||
// for "signed by another key" (1263/AC4).
|
||||
func sign(claims map[string]any, kid string, key *rsa.PrivateKey) string {
|
||||
header, _ := json.Marshal(map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid})
|
||||
payload, _ := json.Marshal(claims)
|
||||
signingInput := b64url(header) + "." + b64url(payload)
|
||||
sum := sha256.Sum256([]byte(signingInput))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return signingInput + "." + b64url(sig)
|
||||
}
|
||||
|
||||
// idToken is a valid ID token's claims, as claimsPatch overrides them (so a test changes exactly one thing —
|
||||
// the audience, the expiry, the signing key — and nothing else).
|
||||
func (fi *fakeIssuer) idToken(clientID, username string, now time.Time, claimsPatch map[string]any, key *rsa.PrivateKey, kid string) string {
|
||||
claims := map[string]any{
|
||||
"iss": fi.Server.URL,
|
||||
"aud": clientID,
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
"iat": now.Unix(),
|
||||
"sub": "user-" + username,
|
||||
"preferred_username": username,
|
||||
}
|
||||
for k, v := range claimsPatch {
|
||||
claims[k] = v
|
||||
}
|
||||
return sign(claims, kid, key)
|
||||
}
|
||||
|
||||
// testAuth is an Auth over fi, with its own callback redirect https://mail.g14.internal/signed-in, and
|
||||
// deterministic time so a test controls what "12 hours ago" or "expired" means.
|
||||
func testAuth(t *testing.T, fi *fakeIssuer, operatorsPath string, now func() time.Time) *Auth {
|
||||
t.Helper()
|
||||
cfg := OIDCConfig{ClientID: "mail-web", ClientSecret: "s3cr3t", Issuer: fi.Server.URL}
|
||||
authenticator, err := NewAuthenticator(context.Background(), cfg, "https://"+InternalHost("g14")+SignedInPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Auth{
|
||||
Auth: authenticator,
|
||||
Sessions: NewSessions(now),
|
||||
Pending: NewPendingAuths(now),
|
||||
Operators: Operators{Path: operatorsPath},
|
||||
Logf: t.Logf,
|
||||
}
|
||||
}
|
||||
|
||||
func operatorsFile(t *testing.T, names ...string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := dir + "/operators.json"
|
||||
raw, _ := json.Marshal(map[string]any{"operators": names})
|
||||
writeFile(t, path, string(raw))
|
||||
return path
|
||||
}
|
||||
|
||||
// extractRefreshURL reads a bounce or callback page's own meta refresh target out of its rendered HTML
|
||||
// (review round 1 finding 1): html/template's own attribute escaping turns "&" into "&" in the URL it
|
||||
// carries (its contextual auto-escaping recognises content="...;url=..." as a URL inside an HTML attribute,
|
||||
// and escapes it as both), so this reverses exactly that, never by building the URL by hand.
|
||||
func extractRefreshURL(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
marker := `content="0; url=`
|
||||
i := strings.Index(body, marker)
|
||||
if i < 0 {
|
||||
t.Fatalf("no meta refresh in the page:\n%s", body)
|
||||
}
|
||||
rest := body[i+len(marker):]
|
||||
j := strings.Index(rest, `"`)
|
||||
if j < 0 {
|
||||
t.Fatalf("the meta refresh's own url is not closed:\n%s", body)
|
||||
}
|
||||
return html.UnescapeString(rest[:j])
|
||||
}
|
||||
|
||||
// followBounce is RequireSession's own one-time bounce (review round 1 finding 1), followed the way a
|
||||
// browser's own meta refresh would: w is the first, cookieless answer (a 200 page of this origin's own,
|
||||
// never a redirect); this reads its refresh target and asks it, answering that second response — the real
|
||||
// 302 to the issuer, since the cookie is still missing on a request this player never carries one on.
|
||||
func followBounce(t *testing.T, s *Server, w *httptest.ResponseRecorder, host string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("the bounce: status %d, want 200 (a page of this origin's own, review round 1 finding 1): %s", w.Code, w.Body.String())
|
||||
}
|
||||
refreshURL := extractRefreshURL(t, w.Body.String())
|
||||
w2 := httptest.NewRecorder()
|
||||
r2 := httptest.NewRequest(http.MethodGet, refreshURL, nil)
|
||||
r2.Host = host
|
||||
s.ServeHTTP(w2, r2)
|
||||
return w2
|
||||
}
|
||||
|
||||
// 1263/AC1: no session: a redirect to the issuer's authorisation endpoint with the client id, a state, a PKCE
|
||||
// challenge, and the redirect https://mail.<node>.internal/signed-in — reached only after RequireSession's own
|
||||
// one-time bounce (review round 1 finding 1), as a browser would, never asked for directly.
|
||||
func TestNoSessionRedirectsToTheIssuersAuthorisationEndpoint(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), nil)
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w, r)
|
||||
w2 := followBounce(t, s, w, InternalHost("g14"))
|
||||
|
||||
if w2.Code != http.StatusFound {
|
||||
t.Fatalf("status %d, want 302", w2.Code)
|
||||
}
|
||||
loc, err := url.Parse(w2.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(loc.String(), fi.Server.URL+"/authorize") {
|
||||
t.Fatalf("redirected to %s, not the issuer's authorisation endpoint", loc)
|
||||
}
|
||||
q := loc.Query()
|
||||
if q.Get("client_id") != "mail-web" {
|
||||
t.Fatalf("client_id: %q", q.Get("client_id"))
|
||||
}
|
||||
if q.Get("state") == "" {
|
||||
t.Fatalf("no state")
|
||||
}
|
||||
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
|
||||
t.Fatalf("no PKCE challenge: %q %q", q.Get("code_challenge"), q.Get("code_challenge_method"))
|
||||
}
|
||||
if q.Get("redirect_uri") != "https://"+InternalHost("g14")+SignedInPath {
|
||||
t.Fatalf("redirect_uri: %q", q.Get("redirect_uri"))
|
||||
}
|
||||
}
|
||||
|
||||
// review round 1 finding 1: a GET with no session cookie is bounced once through this origin's own page (a
|
||||
// 200 with a meta refresh back to the very page asked, marked by BounceParam) rather than sent to the issuer
|
||||
// at once; a second cookieless arrival at that same marked URL goes to sign in directly — no loop — and the
|
||||
// sign-in's own kept return is the page first asked, with the bounce's own marker stripped so it never leaks
|
||||
// into where the browser ends up. A POST with no session cookie is never bounced (a bounce would lose its
|
||||
// body): it goes to sign in directly, the same as it always has.
|
||||
func TestRequireSessionBouncesOnceThenSignsIn(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), nil)
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/folder?account=a&folder=INBOX", nil)
|
||||
r.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("the first cookieless GET: status %d, want 200 (the bounce)", w.Code)
|
||||
}
|
||||
refreshURL := extractRefreshURL(t, w.Body.String())
|
||||
if !strings.HasPrefix(refreshURL, "/folder?") {
|
||||
t.Fatalf("the bounce does not return to the same page: %q", refreshURL)
|
||||
}
|
||||
q, err := url.ParseQuery(strings.SplitN(refreshURL, "?", 2)[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q.Get(BounceParam) == "" {
|
||||
t.Fatalf("the bounce's own refresh does not mark itself: %q", refreshURL)
|
||||
}
|
||||
if q.Get("account") != "a" || q.Get("folder") != "INBOX" {
|
||||
t.Fatalf("the bounce lost the page's own query: %q", refreshURL)
|
||||
}
|
||||
|
||||
// A second cookieless arrival, at the marked URL: sign in directly, no further bounce (no loop).
|
||||
w2 := httptest.NewRecorder()
|
||||
r2 := httptest.NewRequest(http.MethodGet, refreshURL, nil)
|
||||
r2.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w2, r2)
|
||||
if w2.Code != http.StatusFound {
|
||||
t.Fatalf("the second cookieless GET: status %d, want 302 to sign in (no loop)", w2.Code)
|
||||
}
|
||||
loc, err := url.Parse(w2.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(loc.String(), fi.Server.URL+"/authorize") {
|
||||
t.Fatalf("the second cookieless GET did not go to the issuer: %s", loc)
|
||||
}
|
||||
|
||||
// The sign-in's own kept return is the page first asked, the bounce's own marker gone.
|
||||
pending, ok := auth.Pending.Take(loc.Query().Get("state"))
|
||||
if !ok {
|
||||
t.Fatal("no pending sign-in kept under that state")
|
||||
}
|
||||
if pending.Return != "/folder?account=a&folder=INBOX" {
|
||||
t.Fatalf("the sign-in's own return: %q, want the page first asked with no bounce marker", pending.Return)
|
||||
}
|
||||
|
||||
// A POST with no session cookie: sign in directly, never bounced.
|
||||
w3 := httptest.NewRecorder()
|
||||
r3 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(""))
|
||||
r3.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w3, r3)
|
||||
if w3.Code != http.StatusFound {
|
||||
t.Fatalf("a POST with no session cookie: status %d, want 302 (no bounce)", w3.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// startSignIn drives the real shape of starting a sign-in from path, as a browser would (review round 1
|
||||
// finding 2): the first cookieless GET is bounced once (RequireSession's own fix, finding 1); following that
|
||||
// bounce — reading its own meta refresh and asking it, never building the issuer's URL by hand — reaches the
|
||||
// real 302 to the issuer, whose state and PKCE verifier the callback needs.
|
||||
func startSignIn(t *testing.T, s *Server, auth *Auth, path string) (state string) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
r.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w, r)
|
||||
w2 := followBounce(t, s, w, InternalHost("g14"))
|
||||
if w2.Code != http.StatusFound {
|
||||
t.Fatalf("after the bounce: status %d, want 302 to the issuer", w2.Code)
|
||||
}
|
||||
loc, err := url.Parse(w2.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return loc.Query().Get("state")
|
||||
}
|
||||
|
||||
func callback(s *Server, state, code string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, SignedInPath+"?state="+url.QueryEscape(state)+"&code="+url.QueryEscape(code), nil)
|
||||
r.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// 1263/AC2: a valid callback whose ID token names an operator sets a session cookie Secure, HttpOnly,
|
||||
// SameSite=Strict, lasting 12 hours, and sends the browser back to the page it first asked for — a page the
|
||||
// test starts sign-in from that is not also the no-pending-return fallback (review round 1 finding 2: "/" is
|
||||
// both, so a return to it would pass even with pending.Return ignored), through a 200 refresh page, not a 302
|
||||
// (finding 1: a 302 here would carry the Strict cookie just set on a navigation the browser still counts as
|
||||
// begun at Keycloak).
|
||||
func TestACallbackWithAValidIDTokenNamingAnOperatorSetsTheSessionCookie(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
const returnPath = "/folder?account=a&folder=INBOX"
|
||||
state := startSignIn(t, s, auth, returnPath)
|
||||
// The session clock (now) is mocked to test the 12-hour bound (TestASessionTwelveHoursOldIsTreatedAsNoSession
|
||||
// below); the ID token's own iat/exp are judged by go-oidc against the real wall clock, so they are minted
|
||||
// from it, not from the mocked now.
|
||||
fi.nextIDToken = fi.idToken("mail-web", "alice", time.Now(), nil, fi.key, fi.kid)
|
||||
w := callback(s, state, "some-code")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200 (a refresh page, not a redirect): %s", w.Code, w.Body.String())
|
||||
}
|
||||
if refreshURL := extractRefreshURL(t, w.Body.String()); refreshURL != returnPath {
|
||||
t.Fatalf("returned to %q, want the page first asked for (%s)", refreshURL, returnPath)
|
||||
}
|
||||
var found *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == SessionCookie {
|
||||
found = c
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("no session cookie set")
|
||||
}
|
||||
if !found.Secure || !found.HttpOnly || found.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("cookie attributes: Secure=%v HttpOnly=%v SameSite=%v", found.Secure, found.HttpOnly, found.SameSite)
|
||||
}
|
||||
if found.MaxAge != int(SessionLifetime/time.Second) {
|
||||
t.Fatalf("MaxAge %d, want %d (12h)", found.MaxAge, int(SessionLifetime/time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
// 1263/AC3: an ID token naming an identity not in operators is answered 403, and no session cookie is set.
|
||||
func TestACallbackForAnIdentityNotInOperatorsIsRefused(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
now := time.Now()
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
state := startSignIn(t, s, auth, "/")
|
||||
fi.nextIDToken = fi.idToken("mail-web", "mallory", now, nil, fi.key, fi.kid)
|
||||
w := callback(s, state, "some-code")
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", w.Code)
|
||||
}
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Fatalf("a cookie was set for a refused identity: %v", w.Result().Cookies())
|
||||
}
|
||||
}
|
||||
|
||||
// 1263/AC4: a state this module did not issue, an ID token of another audience, one signed by another key, or
|
||||
// one expired — each answered 403, no session made.
|
||||
func TestACallbackIsRefusedOnAnUnknownStateOrAnInvalidIDToken(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
now := time.Now()
|
||||
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(auth *Auth, s *Server) (state string)
|
||||
token func() string
|
||||
}{
|
||||
{"unknown state", func(auth *Auth, s *Server) string { return "a-state-nobody-issued" },
|
||||
func() string { return fi.idToken("mail-web", "alice", now, nil, fi.key, fi.kid) }},
|
||||
{"wrong audience", func(auth *Auth, s *Server) string { return startSignIn(t, s, auth, "/") },
|
||||
func() string { return fi.idToken("someone-else", "alice", now, nil, fi.key, fi.kid) }},
|
||||
{"wrong signing key", func(auth *Auth, s *Server) string { return startSignIn(t, s, auth, "/") },
|
||||
func() string { return fi.idToken("mail-web", "alice", now, nil, otherKey, fi.kid) }},
|
||||
{"expired", func(auth *Auth, s *Server) string { return startSignIn(t, s, auth, "/") },
|
||||
func() string {
|
||||
return fi.idToken("mail-web", "alice", now, map[string]any{"exp": now.Add(-time.Hour).Unix()}, fi.key, fi.kid)
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
state := c.setup(auth, s)
|
||||
fi.nextIDToken = c.token()
|
||||
w := callback(s, state, "some-code")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s: status %d, want 403: %s", c.name, w.Code, w.Body.String())
|
||||
}
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Fatalf("%s: a cookie was set", c.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 1263/AC5: a session made 12 hours ago is treated as no session: a page asked then is redirected to sign in
|
||||
// again.
|
||||
func TestASessionTwelveHoursOldIsTreatedAsNoSession(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
now := start
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
state := startSignIn(t, s, auth, "/")
|
||||
// The ID token's own iat/exp are judged against the real wall clock (see the comment in
|
||||
// TestACallbackWithAValidIDTokenNamingAnOperatorSetsTheSessionCookie above); the session's own age is what
|
||||
// this test mocks, through the session clock (now).
|
||||
fi.nextIDToken = fi.idToken("mail-web", "alice", time.Now(), nil, fi.key, fi.kid)
|
||||
w := callback(s, state, "some-code")
|
||||
var cookie *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == SessionCookie {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
if cookie == nil {
|
||||
t.Fatal("no session cookie set")
|
||||
}
|
||||
|
||||
// Just under 12 hours: still a session.
|
||||
now = start.Add(12*time.Hour - time.Minute)
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = InternalHost("g14")
|
||||
r.AddCookie(cookie)
|
||||
w2 := httptest.NewRecorder()
|
||||
s.ServeHTTP(w2, r)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("just under 12h: status %d, want 200", w2.Code)
|
||||
}
|
||||
|
||||
// Past 12 hours: no session any more — redirected to sign in again.
|
||||
now = start.Add(12*time.Hour + time.Minute)
|
||||
r2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r2.Host = InternalHost("g14")
|
||||
r2.AddCookie(cookie)
|
||||
w3 := httptest.NewRecorder()
|
||||
s.ServeHTTP(w3, r2)
|
||||
if w3.Code != http.StatusFound {
|
||||
t.Fatalf("past 12h: status %d, want 302 (redirect to sign in)", w3.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 1263/AC6: operators empty lets no identity in.
|
||||
func TestCallbackWithEmptyOperatorsLetsNobodyIn(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
now := time.Now()
|
||||
auth := testAuth(t, fi, operatorsFile(t), func() time.Time { return now })
|
||||
s := NewServer(InternalHost("g14"), auth, nil)
|
||||
|
||||
state := startSignIn(t, s, auth, "/")
|
||||
fi.nextIDToken = fi.idToken("mail-web", "alice", now, nil, fi.key, fi.kid)
|
||||
w := callback(s, state, "some-code")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
// The trusted setting `operators` (novox/hq ADR 0440 §3, settlement 5 of design 59 milestone 1's prepare step):
|
||||
// the Keycloak identities (the ID token's preferred_username) let in. Nobody by default; a setting that cannot
|
||||
// be read, is empty, or holds something not its own shape lets nobody in either, and says why (issue 1262/AC4).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Env is where the mesh puts what the module reads, as the manifest's build env names it.
|
||||
type Env struct {
|
||||
Operators string // the trusted setting operators
|
||||
OIDC string // the oidc-client binding, rendered (issue 1262's oidc.json)
|
||||
Listen string // the port the mesh gave endpoint `web` (MESH_MAIL_WEB_LISTEN)
|
||||
Node string // this machine's own name (MESH_NODE), for the internal host mail.<node>.internal
|
||||
}
|
||||
|
||||
func envFromOS() Env {
|
||||
return Env{
|
||||
Operators: os.Getenv("MESH_MAIL_WEB_OPERATORS"),
|
||||
OIDC: os.Getenv("MESH_MAIL_WEB_OIDC"),
|
||||
Listen: os.Getenv("MESH_MAIL_WEB_LISTEN"),
|
||||
Node: os.Getenv("MESH_NODE"),
|
||||
}
|
||||
}
|
||||
|
||||
// OperatorsFile is the shape of the setting as the mesh writes it.
|
||||
type OperatorsFile struct {
|
||||
Operators []string `json:"operators"`
|
||||
}
|
||||
|
||||
// ReadOperators reads the operators setting (issue 1262/AC4): a setting missing, empty, or holding an unknown
|
||||
// field lets nobody in, and the reason is kept (never silently "everyone", never a panic).
|
||||
func ReadOperators(path string) ([]string, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("the module was started without its operators setting (MESH_MAIL_WEB_OPERATORS)")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the operators setting cannot be read: %w", err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var f OperatorsFile
|
||||
if err := dec.Decode(&f); err != nil {
|
||||
return nil, fmt.Errorf("the operators setting is not the module's: %w", err)
|
||||
}
|
||||
return f.Operators, nil
|
||||
}
|
||||
|
||||
// Operators is the setting read fresh each time it is asked (never cached): it who may sign in, so a setting
|
||||
// that cannot be read grants nobody rather than whoever it last allowed.
|
||||
type Operators struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
// Allows says whether identity (the ID token's preferred_username) is let in, and why not when it is refused.
|
||||
func (o Operators) Allows(identity string) (bool, string) {
|
||||
if identity == "" {
|
||||
return false, "no identity was given"
|
||||
}
|
||||
names, err := ReadOperators(o.Path)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
for _, n := range names {
|
||||
if n == identity {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, fmt.Sprintf("%q is not in the setting operators", identity)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1262/AC4: the operators file missing, empty, or holding an unknown field lets nobody in, and says why.
|
||||
func TestOperatorsLetsNobodyInWhenTheSettingIsMissingEmptyOrUnknown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
missing := Operators{Path: filepath.Join(dir, "does-not-exist.json")}
|
||||
if ok, why := missing.Allows("alice"); ok || why == "" {
|
||||
t.Fatalf("missing: allowed=%v why=%q", ok, why)
|
||||
}
|
||||
|
||||
empty := filepath.Join(dir, "empty.json")
|
||||
writeFile(t, empty, ``)
|
||||
if ok, why := (Operators{Path: empty}).Allows("alice"); ok || why == "" {
|
||||
t.Fatalf("empty: allowed=%v why=%q", ok, why)
|
||||
}
|
||||
|
||||
unknown := filepath.Join(dir, "unknown.json")
|
||||
writeFile(t, unknown, `{"operators": ["alice"], "surprise": true}`)
|
||||
if ok, why := (Operators{Path: unknown}).Allows("alice"); ok || why == "" {
|
||||
t.Fatalf("unknown field: allowed=%v why=%q", ok, why)
|
||||
}
|
||||
|
||||
// A well-formed setting still lets nobody in who is not named.
|
||||
good := filepath.Join(dir, "good.json")
|
||||
writeFile(t, good, `{"operators": ["alice"]}`)
|
||||
if ok, _ := (Operators{Path: good}).Allows("bob"); ok {
|
||||
t.Fatalf("bob was let in by a setting naming only alice")
|
||||
}
|
||||
if ok, why := (Operators{Path: good}).Allows("alice"); !ok || why != "" {
|
||||
t.Fatalf("alice: allowed=%v why=%q", ok, why)
|
||||
}
|
||||
}
|
||||
|
||||
// Also 1262/AC4: an empty operators list (the default) lets nobody in.
|
||||
func TestOperatorsDefaultEmptyListLetsNobodyIn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "operators.json")
|
||||
writeFile(t, path, `{"operators": []}`)
|
||||
if ok, why := (Operators{Path: path}).Allows("anyone"); ok || why == "" {
|
||||
t.Fatalf("allowed=%v why=%q", ok, why)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
// The form token bound to the session (novox/hq ADR 0440 §3's last clause, issue 1284): every form that
|
||||
// changes something — today mail_send, once issue 1267 builds the write page — carries a hidden field with
|
||||
// the session's own token (a random 32 bytes, made once per session, Sessions.Create); a POST missing it, or
|
||||
// carrying another session's, is refused 403 before the change it would have made ever runs, compared in
|
||||
// constant time so the check is never a timing side-channel.
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// FormTokenField is the hidden field's own name every changing form carries.
|
||||
const FormTokenField = "csrf_token"
|
||||
|
||||
// FormTokenInput is the hidden field a changing form's own template includes (issue 1284/AC1): bound to the
|
||||
// session handed to the page that renders it (RequireSession, SessionFrom).
|
||||
func FormTokenInput(sess Session) template.HTML {
|
||||
return template.HTML(`<input type="hidden" name="` + FormTokenField + `" value="` +
|
||||
template.HTMLEscapeString(sess.Token) + `">`)
|
||||
}
|
||||
|
||||
// RequireFormToken wraps a POST handler that changes something (novox/hq issue 1284): it calls next only once
|
||||
// the session RequireSession handed on (SessionFrom) and the form's own FormTokenField compare equal, in
|
||||
// constant time; a POST with no token, or another session's, is answered 403 and next is never called, so the
|
||||
// change it would have made — today mail_send — never runs (AC2, AC3). RequireFormToken is placed inside
|
||||
// RequireSession (RequireSession(RequireFormToken(next))): a POST with no session at all is the sign-in
|
||||
// redirect RequireSession itself gives, never reaching here.
|
||||
func RequireFormToken(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, ok := SessionFrom(r)
|
||||
if !ok {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
given := r.PostFormValue(FormTokenField)
|
||||
if given == "" || subtle.ConstantTimeCompare([]byte(given), []byte(sess.Token)) != 1 {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
// The form token bound to the session (novox/hq ADR 0440 §3, issue 1284). There is no write page to test
|
||||
// against yet (issue 1267 builds it); these tests stand in for it with a minimal changing handler ("send"),
|
||||
// proving the mechanism issue 1267 will wire mail_send behind: a session, and that session's own token.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// sessionAndCookie starts a session directly (bypassing sign-in, which auth_test.go already covers on its
|
||||
// own) and answers it with the cookie a request would carry.
|
||||
func sessionAndCookie(t *testing.T, sessions *Sessions, operator string) (Session, *http.Cookie) {
|
||||
t.Helper()
|
||||
id, err := sessions.Create(operator)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sess, ok := sessions.Get(id)
|
||||
if !ok {
|
||||
t.Fatal("the session just created is not there")
|
||||
}
|
||||
return sess, &http.Cookie{Name: SessionCookie, Value: id}
|
||||
}
|
||||
|
||||
// 1284/AC1: a signed-in session's own form carries that session's token.
|
||||
func TestFormTokenInputCarriesTheSessionsOwnToken(t *testing.T) {
|
||||
sess, _ := sessionAndCookie(t, NewSessions(nil), "alice")
|
||||
html := string(FormTokenInput(sess))
|
||||
if !strings.Contains(html, `name="csrf_token"`) || !strings.Contains(html, sess.Token) {
|
||||
t.Fatalf("the form's hidden field does not carry the session's token: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// changingHandler is the stand-in for mail_send (issue 1267 is the real one): it counts how many times it was
|
||||
// actually reached.
|
||||
func changingHandler(calls *int) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
*calls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func postSend(t *testing.T, handler http.HandlerFunc, cookie *http.Cookie, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
form := url.Values{}
|
||||
if token != "" {
|
||||
form.Set(FormTokenField, token)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if cookie != nil {
|
||||
r.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// 1284/AC2: a POST of a send without the token is answered 403, and the change (here, the stand-in) is never
|
||||
// called.
|
||||
func TestPOSTWithoutTheTokenIsRefusedAndTheChangeIsNotCalled(t *testing.T) {
|
||||
sessions := NewSessions(nil)
|
||||
_, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
auth := &Auth{Sessions: sessions}
|
||||
var calls int
|
||||
handler := auth.RequireSession(RequireFormToken(changingHandler(&calls)))
|
||||
|
||||
w := postSend(t, handler, cookie, "")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", w.Code)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("the change was called %d time(s) with no token", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1284/AC3: a POST carrying another session's token is answered 403, and the change is never called.
|
||||
func TestPOSTWithAnotherSessionsTokenIsRefusedAndTheChangeIsNotCalled(t *testing.T) {
|
||||
sessions := NewSessions(nil)
|
||||
_, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
otherSess, _ := sessionAndCookie(t, sessions, "bob")
|
||||
auth := &Auth{Sessions: sessions}
|
||||
var calls int
|
||||
handler := auth.RequireSession(RequireFormToken(changingHandler(&calls)))
|
||||
|
||||
w := postSend(t, handler, cookie, otherSess.Token)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", w.Code)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("the change was called %d time(s) with another session's token", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1284/AC4: a POST carrying its own session's token proceeds.
|
||||
func TestPOSTWithItsOwnSessionsTokenProceeds(t *testing.T) {
|
||||
sessions := NewSessions(nil)
|
||||
sess, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
auth := &Auth{Sessions: sessions}
|
||||
var calls int
|
||||
handler := auth.RequireSession(RequireFormToken(changingHandler(&calls)))
|
||||
|
||||
w := postSend(t, handler, cookie, sess.Token)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("the change was called %d time(s), want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// No session at all: RequireSession's own redirect runs, RequireFormToken is never reached, and the change is
|
||||
// never called either (a POST attempted with no session is treated the same as a page with no session).
|
||||
func TestPOSTWithNoSessionDoesNotReachTheFormTokenCheckOrTheChange(t *testing.T) {
|
||||
fi := newFakeIssuer(t)
|
||||
auth := testAuth(t, fi, operatorsFile(t, "alice"), nil)
|
||||
var calls int
|
||||
handler := auth.RequireSession(RequireFormToken(changingHandler(&calls)))
|
||||
|
||||
w := postSend(t, handler, nil, "whatever")
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("status %d, want 302 (redirect to sign in)", w.Code)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("the change was called %d time(s) with no session", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
// The mail page and attachment download (novox/hq ADR 0440 §5, issue 1266): mail_read already turns a mail's
|
||||
// HTML part into text (mesh-mail's own mime handling); this page renders that text through html/template
|
||||
// alone, so whatever it holds — a script element, a remote image — is shown as escaped text, never parsed as
|
||||
// markup, and nothing on the page names another origin. An attachment is answered as a download: its bytes,
|
||||
// Content-Disposition: attachment with its name escaped, Content-Type application/octet-stream, and
|
||||
// X-Content-Type-Options: nosniff, so a browser never renders it inline. A mail mail-store refuses (an
|
||||
// account not granted) is answered 404, naming nothing of it (issue 1266/AC4) — the same shape as asking for a
|
||||
// mail that was never there.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// readAnswer is mail-store.read's own answer (mesh-mail tools.go readTool / imap.go metadata), the fields the
|
||||
// mail page reads. Attachment carries only the fields a plain read needs; a read with `attachment` given
|
||||
// answers a different shape (attachmentAnswer, below), never both.
|
||||
type readAnswer struct {
|
||||
Account string `json:"account"`
|
||||
Folder string `json:"folder"`
|
||||
UID int `json:"uid"`
|
||||
Date string `json:"date"`
|
||||
From []personResult `json:"from"`
|
||||
To []personResult `json:"to"`
|
||||
Cc []personResult `json:"cc"`
|
||||
Subject string `json:"subject"`
|
||||
Text string `json:"text"`
|
||||
Attachments []attachmentRefShown `json:"attachments"`
|
||||
}
|
||||
|
||||
// attachmentRefShown is one attachment as mail-store.read names it (mesh-mail store.go AttachmentRef): only
|
||||
// the fields the mail page's link and the download both need.
|
||||
type attachmentRefShown struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// attachmentAnswer is mail-store.read's own answer when asked with `attachment` (mesh-mail tools.go readTool).
|
||||
type attachmentAnswer struct {
|
||||
Attachment attachmentRefShown `json:"attachment"`
|
||||
Base64 string `json:"base64"`
|
||||
}
|
||||
|
||||
var mailTemplate = template.Must(template.New("mail").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>{{.Subject}}</title></head><body>
|
||||
<h1>{{.Subject}}</h1>
|
||||
<p>{{.Date}} — from {{range .From}}{{.Name}} <{{.Address}}> {{end}}to {{range .To}}{{.Name}} <{{.Address}}> {{end}}</p>
|
||||
<pre>{{.Text}}</pre>
|
||||
{{if .Attachments}}<h2>Attachments</h2><ul>
|
||||
{{range .Attachments}}<li><a href="/attachment?account={{$.Account}}&folder={{$.Folder}}&uid={{$.UID}}&key={{.Key}}">{{.Name}}</a> ({{.Size}} bytes)</li>{{end}}
|
||||
</ul>{{end}}
|
||||
</body></html>`))
|
||||
|
||||
// mailPage is issue 1266/AC1, AC2, AC4: the mail's headers and its text, through html/template alone (its
|
||||
// auto-escaping) — never the mail's own HTML, and nothing on the page names another origin. A read mail-store
|
||||
// refuses is 404, naming nothing of the mail.
|
||||
func (b *Bundle) mailPage(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
account, folder, uidStr := q.Get("account"), q.Get("folder"), q.Get("uid")
|
||||
uid, err := strconv.Atoi(uidStr)
|
||||
if account == "" || folder == "" || err != nil || uid <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.readAsk(ctx, map[string]any{"account": account, "folder": folder, "uid": float64(uid)})
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var answer readAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
renderPage(w, mailTemplate, answer)
|
||||
}
|
||||
|
||||
// attachmentPage is issue 1266/AC3, AC4: an attachment's bytes as a download — Content-Disposition: attachment
|
||||
// (its name escaped by mime.FormatMediaType, novox/hq ADR 0440 §5), Content-Type application/octet-stream, and
|
||||
// X-Content-Type-Options: nosniff, so a browser never renders it inline. A read mail-store refuses is 404.
|
||||
func (b *Bundle) attachmentPage(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
account, folder, uidStr, key := q.Get("account"), q.Get("folder"), q.Get("uid"), q.Get("key")
|
||||
uid, err := strconv.Atoi(uidStr)
|
||||
if account == "" || folder == "" || key == "" || err != nil || uid <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.readAsk(ctx, map[string]any{
|
||||
"account": account, "folder": folder, "uid": float64(uid), "attachment": key,
|
||||
})
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var answer attachmentAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil || answer.Base64 == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(answer.Base64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
name := answer.Attachment.Name
|
||||
if name == "" {
|
||||
name = "attachment"
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name}))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
// The mail page and attachment download (novox/hq ADR 0440 §5, issue 1266).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1266/AC1, AC2: a mail whose body holds <script>alert(1)</script> and <img src="http://remote/x"> shows that
|
||||
// text escaped, carries no script or img element, and nothing on the page names another origin.
|
||||
func TestMailPageEscapesScriptAndRemoteImageInTheBody(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"account": "mesh", "folder": "INBOX", "uid": 7, "subject": "hi", "date": "2026-01-01T00:00:00Z",
|
||||
"from": []map[string]any{{"address": "bob@example.org"}}, "to": []map[string]any{{"address": "me@example.org"}},
|
||||
"text": `<script>alert(1)</script> and <img src="http://remote.example/x">`,
|
||||
})
|
||||
b := &Bundle{Read: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/mail?account=mesh&folder=INBOX&uid=7", nil)
|
||||
b.mailPage(w, r)
|
||||
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "<script>") || strings.Contains(body, "<img") {
|
||||
t.Fatalf("the page carries a script or img element:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "<script>") || !strings.Contains(body, "<img") {
|
||||
t.Fatalf("the body is not shown escaped as text:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `src="http`) || strings.Contains(body, `href="http`) {
|
||||
t.Fatalf("the page names another origin in a src or href:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Also 1266/AC2: a mail's attachment link names only this server's own path, never another origin.
|
||||
func TestMailPageAttachmentLinksNameOnlyThisServersOwnPath(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"account": "mesh", "folder": "INBOX", "uid": 7, "subject": "hi", "date": "2026-01-01T00:00:00Z",
|
||||
"attachments": []map[string]any{{"key": "abc123", "name": "invoice.pdf", "size": 10, "type": "application/pdf"}},
|
||||
})
|
||||
b := &Bundle{Read: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/mail?account=mesh&folder=INBOX&uid=7", nil)
|
||||
b.mailPage(w, r)
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `href="/attachment?account=mesh&folder=INBOX&uid=7&key=abc123"`) {
|
||||
t.Fatalf("no same-origin attachment link:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 1266/AC3: an attachment asked carries its bytes with Content-Disposition attachment and its name escaped,
|
||||
// Content-Type application/octet-stream, and X-Content-Type-Options nosniff.
|
||||
func TestAttachmentDownloadCarriesTheRightHeadersAndBytes(t *testing.T) {
|
||||
data := []byte("the attachment's own bytes")
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"attachment": map[string]any{"key": "abc123", "name": `weird, name.pdf`, "size": len(data), "type": "application/pdf"},
|
||||
"base64": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
b := &Bundle{Read: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/attachment?account=mesh&folder=INBOX&uid=7&key=abc123", nil)
|
||||
b.attachmentPage(w, r)
|
||||
|
||||
if w.Header().Get("Content-Type") != "application/octet-stream" {
|
||||
t.Fatalf("Content-Type: %q", w.Header().Get("Content-Type"))
|
||||
}
|
||||
if w.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("X-Content-Type-Options: %q", w.Header().Get("X-Content-Type-Options"))
|
||||
}
|
||||
cd := w.Header().Get("Content-Disposition")
|
||||
if !strings.HasPrefix(cd, "attachment;") || !strings.Contains(cd, "weird, name.pdf") {
|
||||
t.Fatalf("Content-Disposition: %q", cd)
|
||||
}
|
||||
if w.Body.String() != string(data) {
|
||||
t.Fatalf("body: %q, want %q", w.Body.String(), data)
|
||||
}
|
||||
}
|
||||
|
||||
// 1266/AC4: a mail mail-store refuses (an account not granted) is answered 404, naming nothing of the mail.
|
||||
func TestMailPageAndAttachmentAre404WhenMailStoreRefuses(t *testing.T) {
|
||||
b := &Bundle{Read: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
return nil, errors.New("refused: mesh-web is not granted the mail account secret-project")
|
||||
}}
|
||||
|
||||
w1 := httptest.NewRecorder()
|
||||
b.mailPage(w1, httptest.NewRequest(http.MethodGet, "/mail?account=secret-project&folder=INBOX&uid=1", nil))
|
||||
if w1.Code != http.StatusNotFound {
|
||||
t.Fatalf("mail page status %d, want 404", w1.Code)
|
||||
}
|
||||
if strings.Contains(w1.Body.String(), "secret-project") {
|
||||
t.Fatalf("the 404 names the account: %s", w1.Body.String())
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
b.attachmentPage(w2, httptest.NewRequest(http.MethodGet, "/attachment?account=secret-project&folder=INBOX&uid=1&key=k", nil))
|
||||
if w2.Code != http.StatusNotFound {
|
||||
t.Fatalf("attachment status %d, want 404", w2.Code)
|
||||
}
|
||||
if strings.Contains(w2.Body.String(), "secret-project") {
|
||||
t.Fatalf("the 404 names the account: %s", w2.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// mesh-mail-web: the mail web client (novox/hq ADR 0440), built from mesh-mail's source as mesh-mail-notifier is
|
||||
// (novox/hq ADR 0198 §1, ADR 0175 §1). A Go bundle the node's runtime launches, its own account (runs-as): it
|
||||
// claims mail-webclient (open, status), reaches mail-store's seat verbs (accounts, search, read, send,
|
||||
// send-status), and — once issue 1283 lands — answers its own HTTP endpoint `web`. No database, no state, no
|
||||
// writable directory beyond the one the runtime account needs for its own settings. What this module says, it
|
||||
// says on stderr; stdout is the MCP channel.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
// The HTTP endpoint's own state (novox/hq ADR 0440 §8; review round 1 of issue 1283, finding 4): before this
|
||||
// fix, a failure setting up sign-in (the issuer never answering) or starting to listen (its port already
|
||||
// taken) was only logged — mail-webclient.status went on answering serves: true, and mail_web_health healthy,
|
||||
// regardless, since neither asked serveHTTP anything. Kept on the Bundle instead, so both tools say the one
|
||||
// truth.
|
||||
const (
|
||||
HTTPSettingUpSignIn = "setting up sign-in"
|
||||
HTTPListening = "listening"
|
||||
HTTPEnded = "ended"
|
||||
)
|
||||
|
||||
// HTTPStatus is serveHTTP's own phase and (once it has one) its error, read by statusTool and healthTool.
|
||||
type HTTPStatus struct {
|
||||
mu sync.Mutex
|
||||
phase string
|
||||
err error
|
||||
}
|
||||
|
||||
// NewHTTPStatus starts HTTPSettingUpSignIn: serveHTTP's own first phase, before issuer discovery has even been
|
||||
// tried once.
|
||||
func NewHTTPStatus() *HTTPStatus { return &HTTPStatus{phase: HTTPSettingUpSignIn} }
|
||||
|
||||
func (h *HTTPStatus) set(phase string, err error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.phase = phase
|
||||
h.err = err
|
||||
}
|
||||
|
||||
// Get answers the endpoint's own phase, and its error once it has one.
|
||||
func (h *HTTPStatus) Get() (phase string, err error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.phase, h.err
|
||||
}
|
||||
|
||||
func main() {
|
||||
b := &Bundle{Env: envFromOS(), HTTP: NewHTTPStatus()}
|
||||
go serveHTTP(b)
|
||||
if err := run(b, os.Stdin, os.Stdout); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// serveHTTP starts the HTTP endpoint `web` (issue 1283), on the port the mesh gave it; a listen it cannot
|
||||
// start is logged, never fatal to the seat verbs stdio.Serve answers on the MCP channel — but it is also kept
|
||||
// on b.HTTP (finding 4), so mail-webclient.status and mail_web_health say it rather than going on claiming to
|
||||
// serve. Sign-in (issue 1263) is set up first, retried on a failure (the issuer may not answer yet): never
|
||||
// served with no session guard at all, which a nil Auth would be (server.go's own fallback, used by its unit
|
||||
// tests alone).
|
||||
func serveHTTP(b *Bundle) {
|
||||
if b.Env.Listen == "" {
|
||||
logf("[mesh-mail-web] started without MESH_MAIL_WEB_LISTEN; its HTTP endpoint is not served")
|
||||
b.HTTP.set(HTTPEnded, errors.New("started without its own listen setting (MESH_MAIL_WEB_LISTEN)"))
|
||||
return
|
||||
}
|
||||
host := InternalHost(b.Env.Node)
|
||||
var auth *Auth
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
a, err := buildAuth(host, b.Env)
|
||||
if err == nil {
|
||||
auth = a
|
||||
break
|
||||
}
|
||||
logf("[mesh-mail-web] sign-in cannot be set up yet (%v); trying again in %s", err, wait)
|
||||
b.HTTP.set(HTTPSettingUpSignIn, err)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
s := NewServer(host, auth, b)
|
||||
b.HTTP.set(HTTPListening, nil)
|
||||
if err := http.ListenAndServe(":"+b.Env.Listen, s); err != nil {
|
||||
logf("[mesh-mail-web] its HTTP endpoint ended: %v", err)
|
||||
b.HTTP.set(HTTPEnded, err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildAuth reads the oidc-client binding (issue 1262's oidc.json) and discovers the issuer (issue 1263).
|
||||
func buildAuth(host string, env Env) (*Auth, error) {
|
||||
cfg, err := ReadOIDCConfig(env.OIDC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authenticator, err := NewAuthenticator(context.Background(), cfg, "https://"+host+SignedInPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Auth{
|
||||
Auth: authenticator,
|
||||
Sessions: NewSessions(nil),
|
||||
Pending: NewPendingAuths(nil),
|
||||
Operators: Operators{Path: env.Operators},
|
||||
Logf: logf,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// run is main's startup path, factored so a test can run it over a pipe instead of the real stdin/stdout
|
||||
// (mirroring mesh-mail-notifier's own run).
|
||||
func run(b *Bundle, in io.Reader, out io.Writer) error {
|
||||
return stdio.ServeOn("", b.Tools(), in, out)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
// The manifest says what the code does (novox/hq ADR 0440 §1, §2, §3, §8; issue 1262).
|
||||
//
|
||||
// 1262/AC5: the repository's own way to run "the controller's manifest rules" is merge-check.sh, which shells
|
||||
// out to the real controller binary ("$MESH_GATE" module check modules/*/module.json, merge-check.sh:127) — not
|
||||
// something this package can call as a unit test, and this batch's brief refuses running merge-check.sh or any
|
||||
// integration test. mesh-mail's and mesh-mail-notifier's own manifest_test.go files do not run the controller
|
||||
// either: they assert the manifest's shape by hand, field by field, in Go. There is no unit-level way in this
|
||||
// repository to run the controller's actual module-check rules, so TestTheManifestSaysWhatTheCodeDoes below is
|
||||
// as far as the repo allows for AC5: it is the same hand-written shape assertion the sibling modules use, now
|
||||
// covering every field AC1 names. The real module check still runs once, for real, through the lead's
|
||||
// mesh-controller.try.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type seat struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
}
|
||||
|
||||
type listen struct {
|
||||
Name string `json:"name"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
From string `json:"from"`
|
||||
}
|
||||
|
||||
type configEntry struct {
|
||||
Type string `json:"type"`
|
||||
Trusted bool `json:"trusted"`
|
||||
Default []string `json:"default"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
RunsAs string `json:"runs-as"`
|
||||
Claims []seat `json:"claims"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Reads []string `json:"reads"`
|
||||
Requires []string `json:"requires"`
|
||||
Contributes map[string]map[string]any `json:"contributes"`
|
||||
Binds map[string]string `json:"binds"`
|
||||
Secrets map[string]string `json:"secrets"`
|
||||
Listens []listen `json:"listens"`
|
||||
Config map[string]configEntry `json:"config"`
|
||||
Tools []string `json:"tools"`
|
||||
State []any `json:"state"`
|
||||
OwnSecrets map[string]string `json:"own-secrets"`
|
||||
SecretsOwner string `json:"secrets-owner"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
From string `json:"from"`
|
||||
Binary string `json:"binary"`
|
||||
Loads []string `json:"loads"`
|
||||
Env map[string]string `json:"env"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// 1262/AC1 (and, as far as the repo allows, AC5 — see the file's own comment above).
|
||||
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
|
||||
if m.Module != "mesh-mail-web" {
|
||||
t.Fatalf("module %q", m.Module)
|
||||
}
|
||||
if m.RunsAs != "mesh-mail-web" {
|
||||
t.Fatalf("runs-as %q, want its own account", m.RunsAs)
|
||||
}
|
||||
wantClaims := []seat{{Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}}}
|
||||
if !reflect.DeepEqual(m.Claims, wantClaims) {
|
||||
t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims)
|
||||
}
|
||||
wantInvokes := []string{
|
||||
"seat:mail-store.accounts", "seat:mail-store.search", "seat:mail-store.read",
|
||||
"seat:mail-store.send", "seat:mail-store.send-status",
|
||||
}
|
||||
if !reflect.DeepEqual(m.Invokes, wantInvokes) {
|
||||
t.Fatalf("invokes %v, want exactly the five mail-store verbs %v", m.Invokes, wantInvokes)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Reads, []string{"mesh-mail.accounts"}) {
|
||||
t.Fatalf("reads %v, want [mesh-mail.accounts]", m.Reads)
|
||||
}
|
||||
if !contains(m.Requires, "route") || !contains(m.Requires, "oidc-client") {
|
||||
t.Fatalf("requires %v, want route and oidc-client", m.Requires)
|
||||
}
|
||||
if len(m.Listens) != 1 || m.Listens[0].Name != "web" || m.Listens[0].From != "mesh" || m.Listens[0].Protocol != "tcp" {
|
||||
t.Fatalf("listens %+v, want exactly one, named web, from the mesh only", m.Listens)
|
||||
}
|
||||
// "list" is the controller's own word for a trusted array value (novox/hq ADR 0348 §4); "array" is not
|
||||
// one of its five types (number, string, boolean, object, list) and the controller refuses it.
|
||||
op, ok := m.Config["operators"]
|
||||
if !ok || op.Type != "list" || !op.Trusted || len(op.Default) != 0 {
|
||||
t.Fatalf("config.operators %+v, want a trusted list defaulting to nobody", op)
|
||||
}
|
||||
// No database, no state bucket, no writable directory beyond the account's own state dir: only the
|
||||
// account (a user resource, not writable), the state directory, and the two files rendered into it.
|
||||
if len(m.State) != 0 {
|
||||
t.Fatalf("state %v, want none", m.State)
|
||||
}
|
||||
for _, dbish := range []string{"postgres-database", "s3-bucket"} {
|
||||
if contains(m.Requires, dbish) {
|
||||
t.Fatalf("requires %v, want no database or object store", m.Requires)
|
||||
}
|
||||
}
|
||||
wantResourceIDs := map[string]bool{"account": true, "state": true, "operators": true, "oidc-config": true}
|
||||
if len(m.Resources) != len(wantResourceIDs) {
|
||||
t.Fatalf("resources %v, want exactly %v", m.Resources, wantResourceIDs)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if id != "account" && r["type"] == "directory" && id != "state" {
|
||||
t.Fatalf("an extra writable directory %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest's own tools match the code's (mirroring mesh-mail's and mesh-mail-notifier's own check).
|
||||
var names []string
|
||||
for _, tool := range (&Bundle{}).Tools() {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Tools, names) {
|
||||
t.Fatalf("tools %v, the code serves %v", m.Tools, names)
|
||||
}
|
||||
|
||||
a := m.Build.Artifacts
|
||||
if len(a) != 2 || a[0].From != "cmd/mesh-mail-web" || a[0].Binary != "mesh-mail-web" || !reflect.DeepEqual(a[0].Loads, []string{"mesh-mail-web"}) {
|
||||
t.Fatalf("build %+v", m.Build)
|
||||
}
|
||||
for k, v := range map[string]string{"MESH_MAIL_WEB_OPERATORS": "operators", "MESH_MAIL_WEB_OIDC": "oidc-config"} {
|
||||
found := false
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == v {
|
||||
found = a[0].Env[k] == r["path"]
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the bundle reads %s at %s, which does not match resource %s's path", k, a[0].Env[k], v)
|
||||
}
|
||||
}
|
||||
// A bundle's word is ${port:<the number a listens entry gives>}, never the endpoint's own name
|
||||
// (novox/hq ADR 0192; mesh-controller internal/catalogue/port_into.go portsFilledInto matches it against
|
||||
// listens[].Port, a plain number, not a label like "web"). len(m.Listens)==1 is already checked above.
|
||||
wantListen := fmt.Sprintf("${port:%d}", m.Listens[0].Port)
|
||||
if a[0].Env["MESH_MAIL_WEB_LISTEN"] != wantListen {
|
||||
t.Fatalf("MESH_MAIL_WEB_LISTEN is %q, want %q (the listens entry's own port number)",
|
||||
a[0].Env["MESH_MAIL_WEB_LISTEN"], wantListen)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,937 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// The runtime player (novox/hq ADR 0192, ADR 0198; issue 1285): mesh-mail-web is a bundle under runs-as, and
|
||||
// reaches the bus only through the runtime that launches it, over stdin and stdout (initialize, tools/list,
|
||||
// tools/call; the bundle's own mesh/ask, mesh/state.<verb>). Nothing in this repository has played that
|
||||
// protocol's runtime side against a real, built bundle binary before (the survey at this issue's prepare step
|
||||
// found no precedent), so this harness takes the wire shape from the SDK's own source, never from memory:
|
||||
// ~/go/pkg/mod/.../mesh-sdk/go@.../stdio.go (the unexported `message`, ServeOn's own switch, c.newID's
|
||||
// "bundle-<n>" ids, live()'s "before Serve" refusal) and state.go (KeptState.ask's "mesh/state."+verb,
|
||||
// {"state":...,"key":...} params). The stand-in mail-store answers seat:mail-store.<verb> and records every
|
||||
// call (AC2); mesh-mail's own account bucket is answered from a real JetStream key-value bucket on a test
|
||||
// nats-server, named as the controller names a module's state (Module+"_"+StateName, mesh-mail's own
|
||||
// acceptance_integration_test.go:79-107) — here "mesh-mail_accounts", since mesh-mail-web's own `reads` names
|
||||
// "mesh-mail.accounts". Compiled by `go vet -tags integration ./...`; never run on this machine (the
|
||||
// context file's hard rule) — the lead's try runs it for real.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// ---- the wire, copied from the SDK's own stdio.go (its unexported `message`) --------------------------------
|
||||
|
||||
// wireMessage mirrors stdio.go's own unexported `message`: newline-framed JSON-RPC, as the runtime and a
|
||||
// bundle exchange it over stdin/stdout. Copied, not imported (it is unexported there) — this harness plays
|
||||
// the runtime's own side of exactly that shape.
|
||||
type wireMessage struct {
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *wireError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type wireError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ---- the stand-in mail-store (issue 1285/AC2, AC4) -------------------------------------------------------
|
||||
|
||||
// askRecord is one ask the bundle made, as the harness recorded it (issue 1285/AC2): its key
|
||||
// ("seat:mail-store.<verb>", stdio.go's own Ask) and its body, whole.
|
||||
type askRecord struct {
|
||||
Key string
|
||||
Body json.RawMessage
|
||||
}
|
||||
|
||||
// mailStoreFunc answers one seat:mail-store.<verb> ask (the key, exactly as stdio.Ask's own caller gives it,
|
||||
// and its body's arguments already read into a map); known is false when the stand-in was not given an
|
||||
// answer for that key at all — the player then fails the test naming the ask, rather than only shaping a
|
||||
// protocol error the bundle's own caller would read as some ordinary failure (issue 1285/AC4).
|
||||
type mailStoreFunc func(key string, args map[string]any) (result any, err error, known bool)
|
||||
|
||||
// standInMailStore is mail-store as the tests in this file and in acceptance_integration_test.go fake it:
|
||||
// one function per seat verb, called by key, and every call recorded (issue 1285/AC2).
|
||||
type standInMailStore struct {
|
||||
mu sync.Mutex
|
||||
|
||||
Accounts func(args map[string]any) (any, error)
|
||||
Search func(args map[string]any) (any, error)
|
||||
Read func(args map[string]any) (any, error)
|
||||
Send func(args map[string]any) (any, error)
|
||||
SendStatus func(args map[string]any) (any, error)
|
||||
|
||||
calls []askRecord
|
||||
}
|
||||
|
||||
// answer is this stand-in's own mailStoreFunc.
|
||||
func (s *standInMailStore) answer(key string, args map[string]any) (any, error, bool) {
|
||||
raw, _ := json.Marshal(args)
|
||||
s.mu.Lock()
|
||||
s.calls = append(s.calls, askRecord{Key: key, Body: raw})
|
||||
s.mu.Unlock()
|
||||
var f func(args map[string]any) (any, error)
|
||||
switch key {
|
||||
case "seat:mail-store.accounts":
|
||||
f = s.Accounts
|
||||
case "seat:mail-store.search":
|
||||
f = s.Search
|
||||
case "seat:mail-store.read":
|
||||
f = s.Read
|
||||
case "seat:mail-store.send":
|
||||
f = s.Send
|
||||
case "seat:mail-store.send-status":
|
||||
f = s.SendStatus
|
||||
default:
|
||||
return nil, nil, false
|
||||
}
|
||||
if f == nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
out, err := f(args)
|
||||
return out, err, true
|
||||
}
|
||||
|
||||
// Calls is every ask this stand-in has answered so far, a copy (issue 1285/AC2, issue 1268's design test 3).
|
||||
func (s *standInMailStore) Calls() []askRecord {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]askRecord(nil), s.calls...)
|
||||
}
|
||||
|
||||
// Keys is every distinct key asked so far, in the order first seen (design test 3's own "only these verbs").
|
||||
func (s *standInMailStore) Keys() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, c := range s.calls {
|
||||
if !seen[c.Key] {
|
||||
seen[c.Key] = true
|
||||
out = append(out, c.Key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- the real account bucket, on a real JetStream bus (issue 1285/AC3) ---------------------------------
|
||||
|
||||
// AccountsBucketName is the real key-value bucket mesh-mail-web's own account bucket read resolves to: the
|
||||
// controller's own naming for a module's state, "<module>_<state>" (mesh-mail's
|
||||
// acceptance_integration_test.go:79-107, Module+"_"+SendsState) — here mesh-mail's own module name and its
|
||||
// accounts state (issue 1255), matching this module's `reads` entry "mesh-mail.accounts".
|
||||
const AccountsBucketName = "mesh-mail_accounts"
|
||||
|
||||
// newTestBus starts a JetStream-enabled nats-server for the test alone, on a random port with its own temp
|
||||
// store — copied from mesh-mail's own cmd/mesh-mail/sender_integration_test.go:55-70 (itself copied there
|
||||
// from modules/mesh-delivery/cmd/mesh-delivery/bus_test.go, the SDK's own testbus being internal).
|
||||
func newTestBus(t *testing.T) string {
|
||||
t.Helper()
|
||||
opts := &server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(),
|
||||
NoLog: true, NoSigs: true}
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("a bus for this test could not be made: %v", err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(30 * time.Second) {
|
||||
s.Shutdown()
|
||||
t.Fatal("a bus for this test did not come up within 30s")
|
||||
}
|
||||
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
|
||||
return s.ClientURL()
|
||||
}
|
||||
|
||||
// newAccountsBucket makes the real key-value bucket mesh-mail's own Mirror would write (issue 1255) on a
|
||||
// fresh test bus, and puts one key per account given — each value mesh-mail-web's own accountBucketValue
|
||||
// (accounts.go), itself copied from mesh-mail's AccountValue (modules/mesh-mail/cmd/mesh-mail/mirror.go,
|
||||
// issue 1255; accounts.go's own comment names the source again). Answers the jetstream.KeyValue the player
|
||||
// reads mesh-mail.accounts from, and the *nats.Conn/jetstream.JetStream for a test to write more keys with
|
||||
// (issue 1285/AC3).
|
||||
func newAccountsBucket(t *testing.T, accounts map[string]accountBucketValue) (jetstream.JetStream, jetstream.KeyValue) {
|
||||
t.Helper()
|
||||
url := newTestBus(t)
|
||||
nc, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: AccountsBucketName})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for account, v := range accounts {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put(ctx, account, raw); err != nil {
|
||||
t.Fatalf("putting the account bucket's key %q: %v", account, err)
|
||||
}
|
||||
}
|
||||
return js, kv
|
||||
}
|
||||
|
||||
// ---- building the bundle's own binary (issue 1285) ---------------------------------------------------------
|
||||
|
||||
// buildMailWebBinary builds mesh-mail-web's own binary into TMPDIR (the brief's own rule: a short TMPDIR,
|
||||
// issue 1244's long-path lesson), once per test.
|
||||
func buildMailWebBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "mw-web-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
bin := filepath.Join(dir, "mesh-mail-web")
|
||||
cmd := exec.Command("go", "build", "-o", bin, ".")
|
||||
cmd.Dir, err = os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.Env = os.Environ()
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("building mesh-mail-web: %v\n%s", err, out)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
// freePort answers a TCP port free at the moment it is asked — released before the bundle (a separate
|
||||
// process, so this harness cannot hand it a listener directly) is told to bind it. A small, accepted race,
|
||||
// the same shape as picking any port ahead of starting an external process.
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// ---- the runtime player itself (issue 1285) ------------------------------------------------------------
|
||||
|
||||
// askFailer is the small part of *testing.T the runtime player reports an unknown ask through (Helper,
|
||||
// Errorf) — never the whole of it. Every ordinary test gives its own *testing.T (which has both). Issue
|
||||
// 1285/AC4's own test gives a recorder instead: a failed subtest fails its parent (t.Run answers false, but
|
||||
// the parent is marked failed regardless, try 2's own finding), which would make "the harness fails the
|
||||
// test" itself untestable the direct way.
|
||||
type askFailer interface {
|
||||
Helper()
|
||||
Errorf(format string, args ...any)
|
||||
}
|
||||
|
||||
// runtimePlayer plays the node's runtime against one started bundle, over its real stdin and stdout:
|
||||
// initialize, tools/list, tools/call (this harness's own outgoing requests, stdio.go's ServeOn switch), and
|
||||
// answers to what the bundle itself asks — mesh/ask, mesh/state.<verb> (the bundle's own outgoing requests,
|
||||
// stdio.go's channel.ask / state.go's KeptState.ask).
|
||||
type runtimePlayer struct {
|
||||
fail askFailer
|
||||
cmd *exec.Cmd
|
||||
in io.WriteCloser
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
waiting map[string]chan wireMessage
|
||||
|
||||
mailStore mailStoreFunc
|
||||
js jetstream.JetStream
|
||||
}
|
||||
|
||||
// startRuntimePlayer starts binary with env, and begins playing the runtime on its stdin/stdout at once: the
|
||||
// handshake (initialize, notifications/initialized) is done here, so a caller gets back a player already
|
||||
// past it, exactly as the SDK's own live() expects before any Ask (stdio.go:391-398, live_before_serve_test.go).
|
||||
// t is used for the process's own setup and cleanup (Fatal, Cleanup) always; failer is where an unknown ask
|
||||
// is reported (Errorf) — t itself when nil, a recorder for issue 1285/AC4's own test.
|
||||
func startRuntimePlayer(t *testing.T, binary string, env []string, mailStore mailStoreFunc, js jetstream.JetStream, failer askFailer) *runtimePlayer {
|
||||
t.Helper()
|
||||
if failer == nil {
|
||||
failer = t
|
||||
}
|
||||
cmd := exec.Command(binary)
|
||||
cmd.Env = env
|
||||
stderr := &prefixedLog{t: t, prefix: "mesh-mail-web stderr: "}
|
||||
cmd.Stderr = stderr
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &runtimePlayer{fail: failer, cmd: cmd, in: stdin, waiting: map[string]chan wireMessage{}, mailStore: mailStore, js: js}
|
||||
go p.readLoop(stdout)
|
||||
t.Cleanup(func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
if _, err := p.ask("initialize", map[string]any{
|
||||
"protocolVersion": "2025-03-26", "capabilities": map[string]any{}, "clientInfo": map[string]any{"name": "test", "version": "1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
if err := p.send(map[string]any{"jsonrpc": "2.0", "method": "notifications/initialized", "params": map[string]any{}}); err != nil {
|
||||
t.Fatalf("notifications/initialized: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// prefixedLog writes the bundle's own stderr to the test log, a line at a time, so a failure's cause (a
|
||||
// panic, a refused build, an unreachable issuer) is in the test's own output rather than lost.
|
||||
type prefixedLog struct {
|
||||
t *testing.T
|
||||
prefix string
|
||||
buf []byte
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (p *prefixedLog) Write(b []byte) (int, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.buf = append(p.buf, b...)
|
||||
for {
|
||||
i := strings.IndexByte(string(p.buf), '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
p.t.Log(p.prefix + string(p.buf[:i]))
|
||||
p.buf = p.buf[i+1:]
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
// readLoop reads every line the bundle writes to its own stdout: a line with no "method" and an id is an
|
||||
// answer to something this player asked (stdio.go's own channel read loop tells them apart the same way,
|
||||
// stdio.go:181-191); a line with a "method" is a request the bundle itself is making, answered by
|
||||
// handleBundleRequest.
|
||||
func (p *runtimePlayer) readLoop(stdout io.Reader) {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 1<<20), 1<<24)
|
||||
for scanner.Scan() {
|
||||
var m wireMessage
|
||||
if json.Unmarshal(scanner.Bytes(), &m) != nil {
|
||||
continue
|
||||
}
|
||||
if m.Method == "" && len(m.ID) > 0 {
|
||||
var key string
|
||||
_ = json.Unmarshal(m.ID, &key)
|
||||
p.mu.Lock()
|
||||
ch := p.waiting[key]
|
||||
delete(p.waiting, key)
|
||||
p.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- m
|
||||
}
|
||||
continue
|
||||
}
|
||||
if m.Method != "" {
|
||||
go p.handleBundleRequest(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send writes one JSON-RPC object, newline-framed, to the bundle's stdin — this player's own half of the
|
||||
// wire (stdio.go's channel.say).
|
||||
func (p *runtimePlayer) send(m map[string]any) error {
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_, err = p.in.Write(append(raw, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
// ask sends one request to the bundle (initialize, tools/list, tools/call — this player's own outgoing
|
||||
// calls) and waits for its answer, under an id of this player's own choosing (never "bundle-<n>", which the
|
||||
// bundle's own outgoing ids use, stdio.go's channel.newID — so the two id spaces never collide on one wire).
|
||||
func (p *runtimePlayer) ask(method string, params any) (json.RawMessage, error) {
|
||||
p.mu.Lock()
|
||||
p.nextID++
|
||||
id := "player-" + strconv.Itoa(p.nextID)
|
||||
ch := make(chan wireMessage, 1)
|
||||
p.waiting[id] = ch
|
||||
p.mu.Unlock()
|
||||
|
||||
if err := p.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case m := <-ch:
|
||||
if m.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", method, m.Error.Message)
|
||||
}
|
||||
return m.Result, nil
|
||||
case <-time.After(30 * time.Second):
|
||||
p.mu.Lock()
|
||||
delete(p.waiting, id)
|
||||
p.mu.Unlock()
|
||||
return nil, fmt.Errorf("%s: no answer within 30s", method)
|
||||
}
|
||||
}
|
||||
|
||||
// ToolsList is tools/list, for a test to check what the bundle serves.
|
||||
func (p *runtimePlayer) ToolsList() ([]string, error) {
|
||||
raw, err := p.ask("tools/list", map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(out.Tools))
|
||||
for _, t := range out.Tools {
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// ToolsCall is tools/call, answering the tool's own text content (stdio.go's own "content":[{"type":"text",
|
||||
// "text":...}] shape), and whether the runtime itself said isError.
|
||||
func (p *runtimePlayer) ToolsCall(name string, args map[string]any) (text string, isError bool, err error) {
|
||||
raw, askErr := p.ask("tools/call", map[string]any{"name": name, "arguments": args})
|
||||
if askErr != nil {
|
||||
return "", false, askErr
|
||||
}
|
||||
var out struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if len(out.Content) == 0 {
|
||||
return "", out.IsError, nil
|
||||
}
|
||||
return out.Content[0].Text, out.IsError, nil
|
||||
}
|
||||
|
||||
// handleBundleRequest answers one request the bundle itself made (mesh/ask, mesh/state.<verb>) — never one
|
||||
// this player sent (those are answers, read.Loop already told apart and never reach here).
|
||||
func (p *runtimePlayer) handleBundleRequest(m wireMessage) {
|
||||
p.fail.Helper()
|
||||
reply := func(result any, errMsg string) {
|
||||
out := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(m.ID)}
|
||||
if errMsg != "" {
|
||||
out["error"] = map[string]any{"code": -32000, "message": errMsg}
|
||||
} else {
|
||||
raw, _ := json.Marshal(result)
|
||||
out["result"] = json.RawMessage(raw)
|
||||
}
|
||||
_ = p.send(out)
|
||||
}
|
||||
switch {
|
||||
case m.Method == "mesh/ask":
|
||||
p.handleAsk(m, reply)
|
||||
case strings.HasPrefix(m.Method, "mesh/state."):
|
||||
p.handleState(m, reply)
|
||||
default:
|
||||
p.fail.Errorf("the bundle asked %s, which the helper does not know (issue 1285/AC4)", m.Method)
|
||||
reply(nil, "the helper does not know "+m.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAsk is mesh/ask (stdio.go's Ask, params {"key":...,"body":...}): the stand-in mail-store's own
|
||||
// answer, or — a key the stand-in was given nothing for — the test failing, naming the ask (issue 1285/AC4).
|
||||
func (p *runtimePlayer) handleAsk(m wireMessage, reply func(result any, errMsg string)) {
|
||||
p.fail.Helper()
|
||||
var params struct {
|
||||
Key string `json:"key"`
|
||||
Body map[string]any `json:"body"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Params, ¶ms)
|
||||
if p.mailStore == nil {
|
||||
p.fail.Errorf("the bundle asked %s, which the helper does not know (issue 1285/AC4)", params.Key)
|
||||
reply(nil, "the helper does not know "+params.Key)
|
||||
return
|
||||
}
|
||||
result, err, known := p.mailStore(params.Key, params.Body)
|
||||
if !known {
|
||||
p.fail.Errorf("the bundle asked %s, which the helper does not know (issue 1285/AC4)", params.Key)
|
||||
reply(nil, "the helper does not know "+params.Key)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
reply(nil, err.Error())
|
||||
return
|
||||
}
|
||||
reply(result, "")
|
||||
}
|
||||
|
||||
// handleState is mesh/state.<verb> (state.go's KeptState.ask, params {"state":"<module>.<name>", ...}):
|
||||
// get and keys answered from the real JetStream bucket this player was given (issue 1285/AC3); any other
|
||||
// verb, or a `state` this player holds no bucket for, fails the test naming the ask (AC4).
|
||||
func (p *runtimePlayer) handleState(m wireMessage, reply func(result any, errMsg string)) {
|
||||
p.fail.Helper()
|
||||
verb := strings.TrimPrefix(m.Method, "mesh/state.")
|
||||
var params struct {
|
||||
State string `json:"state"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Params, ¶ms)
|
||||
fail := func() {
|
||||
p.fail.Errorf("the bundle asked %s for state %s, which the helper does not know (issue 1285/AC4)", m.Method, params.State)
|
||||
reply(nil, "the helper does not know mesh/state."+verb+" for "+params.State)
|
||||
}
|
||||
// Only mesh-mail.accounts is wired to a real bucket (issue 1285's own scope); any other `state` is
|
||||
// unknown to this player.
|
||||
if params.State != "mesh-mail.accounts" || p.js == nil {
|
||||
fail()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
kv, err := p.js.KeyValue(ctx, AccountsBucketName)
|
||||
if err != nil {
|
||||
reply(nil, err.Error())
|
||||
return
|
||||
}
|
||||
switch verb {
|
||||
case "get":
|
||||
entry, err := kv.Get(ctx, params.Key)
|
||||
if err != nil {
|
||||
if err == jetstream.ErrKeyNotFound {
|
||||
reply(nil, "")
|
||||
return
|
||||
}
|
||||
reply(nil, err.Error())
|
||||
return
|
||||
}
|
||||
reply(map[string]any{"key": params.Key, "value": json.RawMessage(entry.Value()), "revision": entry.Revision()}, "")
|
||||
case "keys":
|
||||
keys, err := kv.Keys(ctx)
|
||||
if err != nil && err != jetstream.ErrNoKeysFound {
|
||||
reply(nil, err.Error())
|
||||
return
|
||||
}
|
||||
reply(keys, "")
|
||||
default:
|
||||
fail()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- a started mesh-mail-web, ready for a test (issue 1285, reused by acceptance_integration_test.go) ------
|
||||
|
||||
// testClientID is the oidc-client binding's own client id these tests write (issue 1262's oidc.json), and
|
||||
// the audience the stand-in issuer's own ID tokens are minted for (auth_test.go's fi.idToken) — the two must
|
||||
// agree, exactly as mesh-mail-web's own real oidc.json and Keycloak's real client would.
|
||||
const testClientID = "mesh-mail-web-test"
|
||||
|
||||
// testNode is MESH_NODE for these tests: InternalHost(testNode) is the Host every request carries, as the
|
||||
// operator's own setting would assign it on a real machine (ADR 0440 §2).
|
||||
const testNode = "test-node"
|
||||
|
||||
// startedMailWeb is one running mesh-mail-web, built and started by the helper, with its stand-in mail-store,
|
||||
// its real account bucket, its stand-in identity provider, and an HTTP client that reaches its endpoint —
|
||||
// everything issue 1285's own tests, and issue 1268's acceptance tests, need.
|
||||
type startedMailWeb struct {
|
||||
player *runtimePlayer
|
||||
store *standInMailStore
|
||||
fakeIssuer *fakeIssuer
|
||||
client *http.Client
|
||||
internalHost string
|
||||
js jetstream.JetStream
|
||||
}
|
||||
|
||||
// startMailWeb is startMailWebWithFailer with the test's own *testing.T as the ask failer (every ordinary
|
||||
// test).
|
||||
func startMailWeb(t *testing.T, accounts map[string]accountBucketValue, operators []string) *startedMailWeb {
|
||||
t.Helper()
|
||||
return startMailWebWithFailer(t, accounts, operators, nil)
|
||||
}
|
||||
|
||||
// startMailWebWithFailer builds mesh-mail-web's own binary, starts it with a real account bucket (one key
|
||||
// per entry in accounts) and the trusted setting operators, and plays the runtime on it until initialize and
|
||||
// notifications/initialized are done. The stand-in mail-store (startedMailWeb.store) answers nothing until a
|
||||
// test sets its own fields. failer is where an unknown ask is reported (issue 1285/AC4): nil for the test's
|
||||
// own *testing.T, or a recorder for a test that must assert on that report without failing itself.
|
||||
func startMailWebWithFailer(t *testing.T, accounts map[string]accountBucketValue, operators []string, failer askFailer) *startedMailWeb {
|
||||
t.Helper()
|
||||
binary := buildMailWebBinary(t)
|
||||
port := freePort(t)
|
||||
internalHost := InternalHost(testNode)
|
||||
|
||||
js, _ := newAccountsBucket(t, accounts)
|
||||
fi := newFakeIssuer(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
opJSON, err := json.Marshal(map[string]any{"operators": operators})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opPath := filepath.Join(dir, "operators.json")
|
||||
if err := os.WriteFile(opPath, opJSON, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oidcJSON, err := json.Marshal(OIDCConfig{ClientID: testClientID, ClientSecret: "s3cr3t", Issuer: fi.Server.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oidcPath := filepath.Join(dir, "oidc.json")
|
||||
if err := os.WriteFile(oidcPath, oidcJSON, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := &standInMailStore{}
|
||||
env := append(os.Environ(),
|
||||
"MESH_MAIL_WEB_LISTEN="+strconv.Itoa(port),
|
||||
"MESH_NODE="+testNode,
|
||||
"MESH_MAIL_WEB_OPERATORS="+opPath,
|
||||
"MESH_MAIL_WEB_OIDC="+oidcPath,
|
||||
"MESH_SERVED_MODULE=mesh-mail-web",
|
||||
)
|
||||
player := startRuntimePlayer(t, binary, env, store.answer, js, failer)
|
||||
waitForHTTP(t, port)
|
||||
|
||||
return &startedMailWeb{
|
||||
player: player, store: store, fakeIssuer: fi,
|
||||
client: httpClientForPort(port), internalHost: internalHost, js: js,
|
||||
}
|
||||
}
|
||||
|
||||
// httpClientForPort is an http.Client that reaches 127.0.0.1:port for any request, whatever Host the
|
||||
// request itself carries — so a test sends Host: mail.<node>.internal exactly as the route proxy would,
|
||||
// never by editing /etc/hosts (the mesh's own hard rule) or needing a real DNS entry. It never follows a
|
||||
// redirect on its own, so a test reads a 302's own Location (the sign-in redirect, issue 1263).
|
||||
func httpClientForPort(port int) *http.Client {
|
||||
return &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, fmt.Sprintf("127.0.0.1:%d", port))
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// waitForHTTP polls 127.0.0.1:port until it accepts a connection: the HTTP endpoint comes up only once
|
||||
// sign-in's own issuer discovery has succeeded (main.go's serveHTTP retries buildAuth until it does).
|
||||
func waitForHTTP(t *testing.T, port int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("mesh-mail-web's HTTP endpoint did not come up within 30s")
|
||||
}
|
||||
|
||||
// followSignInBounce is a plain GET to path carrying no session cookie, followed through RequireSession's own
|
||||
// one-time bounce (review round 1 finding 1: the first cookieless answer is a 200 page of this origin's own,
|
||||
// never a redirect — a browser's own meta refresh is what reaches the real one) exactly the way a browser
|
||||
// would: read the bounce's own refresh target and ask it. Answers that second response: the 302 to the
|
||||
// issuer, since the cookie is still missing on a request this client never carries one on.
|
||||
func followSignInBounce(t *testing.T, mw *startedMailWeb, path string) *http.Response {
|
||||
t.Helper()
|
||||
resp := doWithCookie(t, mw, http.MethodGet, path, nil, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s with no session: status %d, want 200 (RequireSession's own one-time bounce, review round 1 finding 1)", path, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return doWithCookie(t, mw, http.MethodGet, extractMetaRefreshURL(t, string(body)), nil, nil)
|
||||
}
|
||||
|
||||
// signIn drives issue 1263's own flow through HTTP alone, as a browser would, never by calling a handler
|
||||
// directly: it asks path (any page behind RequireSession) with no session, follows RequireSession's own
|
||||
// one-time bounce (review round 1 finding 1), reads the state the resulting redirect to the stand-in issuer
|
||||
// carries, and calls back mesh-mail-web's own /signed-in with a fresh ID token the issuer mints for username.
|
||||
// Answers the session cookie mesh-mail-web set — the callback's own answer is a 200 refresh page too
|
||||
// (finding 1), but Set-Cookie is read from it exactly the same way a 302's would have been.
|
||||
func signIn(t *testing.T, mw *startedMailWeb, username, path string) *http.Cookie {
|
||||
t.Helper()
|
||||
resp := followSignInBounce(t, mw, path)
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("asking %s with no session: status %d, want 302 to sign in", path, resp.StatusCode)
|
||||
}
|
||||
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := loc.Query().Get("state")
|
||||
if state == "" {
|
||||
t.Fatalf("the redirect to sign in carries no state: %s", resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
mw.fakeIssuer.nextIDToken = mw.fakeIssuer.idToken(testClientID, username, time.Now(), nil, mw.fakeIssuer.key, mw.fakeIssuer.kid)
|
||||
cbReq, err := http.NewRequest(http.MethodGet,
|
||||
"http://"+mw.internalHost+SignedInPath+"?state="+url.QueryEscape(state)+"&code=test-code", 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()
|
||||
for _, c := range cbResp.Cookies() {
|
||||
if c.Name == SessionCookie {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatalf("sign-in did not set a session cookie for %q (callback status %d)", username, cbResp.StatusCode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCSP is review round 1 finding 3's own strengthening: the content security policy (ADR 0440 §5) is
|
||||
// Server.ServeHTTP's own first line, set on every answer whatever else happens to it (a page, a 404, a
|
||||
// sign-in bounce or redirect alike) — so every request these tests drive through doWithCookie and doForm
|
||||
// checks it, rather than the three hand-picked answers the design test asked of it before.
|
||||
func checkCSP(t *testing.T, method, path string, resp *http.Response) {
|
||||
t.Helper()
|
||||
if got := resp.Header.Get("Content-Security-Policy"); got != CSP {
|
||||
t.Fatalf("%s %s: Content-Security-Policy %q, want %q", method, path, got, CSP)
|
||||
}
|
||||
}
|
||||
|
||||
// doWithCookie is one request to mw's own endpoint, the session cookie attached, Host set to its internal
|
||||
// name — the shape every page, attachment and send request in these tests shares. Every answer's own CSP is
|
||||
// checked here (review round 1 finding 3), not left to a handful of tests to ask for themselves.
|
||||
func doWithCookie(t *testing.T, mw *startedMailWeb, method, path string, body io.Reader, cookie *http.Cookie) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, "http://"+mw.internalHost+path, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Host = mw.internalHost
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
resp, err := mw.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkCSP(t, method, path, resp)
|
||||
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. Every answer's own CSP is checked here too (finding 3).
|
||||
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)
|
||||
}
|
||||
checkCSP(t, http.MethodPost, path, resp)
|
||||
return resp
|
||||
}
|
||||
|
||||
// extractMetaRefreshURL reads a page's own meta refresh target out of its rendered HTML (review round 1
|
||||
// finding 1, finding 3): RequireSession's own bounce, the callback's own return, and the write page's own
|
||||
// send-status poll (write.go's sendStatusTemplate) all carry one, whatever its own delay. html/template's
|
||||
// contextual auto-escaping recognises content="...;url=..." inside a meta tag as a URL within an HTML
|
||||
// attribute, and escapes it as both (an "&" becomes "&"); html.UnescapeString reverses exactly that, so
|
||||
// this never builds the URL by hand — it reads the one the page itself carries, as a browser's own meta
|
||||
// refresh would.
|
||||
func extractMetaRefreshURL(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
marker := "; url="
|
||||
i := strings.Index(body, marker)
|
||||
if i < 0 {
|
||||
t.Fatalf("no meta refresh in the page:\n%s", body)
|
||||
}
|
||||
rest := body[i+len(marker):]
|
||||
j := strings.Index(rest, `"`)
|
||||
if j < 0 {
|
||||
t.Fatalf("the meta refresh's own url is not closed:\n%s", body)
|
||||
}
|
||||
return html.UnescapeString(rest[:j])
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestHarnessInitializeToolsListAndStatusCall(t *testing.T) {
|
||||
mw := startMailWeb(t, nil, nil)
|
||||
mw.store.Accounts = func(map[string]any) (any, error) {
|
||||
return map[string]any{"accounts": []any{}}, nil
|
||||
}
|
||||
names, err := mw.player.ToolsList()
|
||||
if err != nil {
|
||||
t.Fatalf("tools/list: %v", err)
|
||||
}
|
||||
if !containsName(names, StatusTool) {
|
||||
t.Fatalf("tools/list does not name %s: %v", StatusTool, names)
|
||||
}
|
||||
text, isError, err := mw.player.ToolsCall(StatusTool, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("tools/call %s: %v", StatusTool, err)
|
||||
}
|
||||
if isError || text == "" {
|
||||
t.Fatalf("%s answered isError=%v, text=%q", StatusTool, isError, text)
|
||||
}
|
||||
}
|
||||
|
||||
// 1285/AC2: the bundle's ask of seat:mail-store.<verb> is answered by the stand-in, and recorded with its
|
||||
// arguments.
|
||||
func TestHarnessRecordsASeatAskWithItsArguments(t *testing.T) {
|
||||
mw := startMailWeb(t, nil, nil)
|
||||
mw.store.Accounts = func(map[string]any) (any, error) {
|
||||
return map[string]any{"accounts": []any{map[string]any{"account": "a"}}}, nil
|
||||
}
|
||||
if _, _, err := mw.player.ToolsCall(StatusTool, map[string]any{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := mw.store.Calls()
|
||||
if len(calls) != 1 || calls[0].Key != "seat:mail-store.accounts" {
|
||||
t.Fatalf("calls recorded: %+v", calls)
|
||||
}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal(calls[0].Body, &args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(args) != 0 {
|
||||
t.Fatalf("mail-webclient.status's own accounts ask carries arguments: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// 1285/AC3: a value put in the real key-value bucket for account a is what the bundle's own state read of
|
||||
// mesh-mail.accounts key a answers — driven through the accounts page (accounts.go), the one production
|
||||
// path that reads it; there is no tool exposing the bucket read directly.
|
||||
func TestHarnessStateReadAnswersTheRealBucketsValue(t *testing.T) {
|
||||
mw := startMailWeb(t, map[string]accountBucketValue{
|
||||
"a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 7, Unread: 3}}},
|
||||
}, []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
|
||||
}
|
||||
cookie := signIn(t, mw, "alice", "/")
|
||||
resp := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
text := string(body)
|
||||
for _, want := range []string{"INBOX", "7 mail(s)", "3 unread"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("the page does not show the real bucket's value (%q missing):\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// askFailRecorder is a fake askFailer: it keeps every message instead of failing a test (try 2's own
|
||||
// finding — a failed subtest fails its parent through t.Run, which would make "the harness fails the test"
|
||||
// itself untestable the direct way), so issue 1285/AC4's own test can assert on what was reported without
|
||||
// failing itself.
|
||||
type askFailRecorder struct {
|
||||
mu sync.Mutex
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (r *askFailRecorder) Helper() {}
|
||||
|
||||
func (r *askFailRecorder) Errorf(format string, args ...any) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.messages = append(r.messages, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (r *askFailRecorder) Messages() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.messages...)
|
||||
}
|
||||
|
||||
// 1285/AC4: the bundle asking something the helper does not know is reported through the ask failer, naming
|
||||
// the ask, and the bundle's own ask gets an error answer back.
|
||||
func TestHarnessFailsTheTestWhenTheBundleAsksSomethingUnknown(t *testing.T) {
|
||||
recorder := &askFailRecorder{}
|
||||
mw := startMailWebWithFailer(t, nil, nil, recorder)
|
||||
// mw.store.Accounts is left nil on purpose: mail-webclient.status's own ask of seat:mail-store.accounts
|
||||
// is then unknown to the stand-in.
|
||||
text, isError, err := mw.player.ToolsCall(StatusTool, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("tools/call: %v", err)
|
||||
}
|
||||
if isError {
|
||||
t.Fatalf("mail-webclient.status answered isError; it catches the ask's own error itself: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "does not answer") {
|
||||
t.Fatalf("mail-webclient.status's own text does not show the ask got an error answer: %s", text)
|
||||
}
|
||||
messages := recorder.Messages()
|
||||
if len(messages) != 1 || !strings.Contains(messages[0], "seat:mail-store.accounts") {
|
||||
t.Fatalf("the recorder's messages: %v, want exactly one naming seat:mail-store.accounts", messages)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
// The search page (novox/hq ADR 0440 §4, issue 1265): the form's own field names are exactly mail-store.search's
|
||||
// own argument names (mesh-mail tools.go searchArgs, issue 1232), so a filled field maps straight onto the
|
||||
// seat's own argument of the same name; an empty field is left out of the call entirely, never sent as "".
|
||||
// Paged by date exactly as the folder page (issue 1264): 50 a page, the next link asking again with before the
|
||||
// last shown mail's own date.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var searchTemplate = template.Must(template.New("search").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>Search mail</title></head><body>
|
||||
<h1>Search</h1>
|
||||
<form method="get" action="/search">
|
||||
<input type="text" name="query" placeholder="words" value="{{.Query}}">
|
||||
<input type="text" name="from" placeholder="from" value="{{.From}}">
|
||||
<input type="text" name="to" placeholder="to" value="{{.To}}">
|
||||
<input type="text" name="on-or-after" placeholder="on or after" value="{{.OnOrAfter}}">
|
||||
<input type="text" name="before" placeholder="before" value="{{.Before}}">
|
||||
<label><input type="checkbox" name="has-attachment" value="on"{{if .HasAttachment}} checked{{end}}> has attachment</label>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
{{if .Error}}<p>{{.Error}}</p>{{else}}
|
||||
<ul>
|
||||
{{range .Mails}}<li>{{.Date}} — {{range .From}}{{.Name}} <{{.Address}}> {{end}}— <a href="/mail?account={{.Account}}&folder={{.Folder}}&uid={{.UID}}">{{.Subject}}</a></li>{{end}}
|
||||
</ul>
|
||||
{{if .NextBefore}}<p><a href="{{.NextLink}}">Older</a></p>{{end}}
|
||||
{{end}}
|
||||
</body></html>`))
|
||||
|
||||
type searchTemplateData struct {
|
||||
Query, From, To, OnOrAfter, Before, Error, NextBefore, NextLink string
|
||||
HasAttachment bool
|
||||
Mails []searchResult
|
||||
}
|
||||
|
||||
// searchArgsFromForm builds mail-store.search's own arguments from the request's query parameters: a field
|
||||
// left empty is left out of the call entirely (issue 1265/AC1) — never sent empty. "from" is split into a list
|
||||
// on whitespace or commas, mail-store.search's own array argument. limit is always MostFolderResults (AC2),
|
||||
// mechanical paging the form itself never carries.
|
||||
func searchArgsFromForm(q map[string][]string) map[string]any {
|
||||
get := func(name string) string {
|
||||
if v, ok := q[name]; ok && len(v) > 0 {
|
||||
return strings.TrimSpace(v[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
args := map[string]any{"limit": float64(MostFolderResults)}
|
||||
if v := get("query"); v != "" {
|
||||
args["query"] = v
|
||||
}
|
||||
if v := get("from"); v != "" {
|
||||
var list []string
|
||||
for _, part := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' }) {
|
||||
if part != "" {
|
||||
list = append(list, part)
|
||||
}
|
||||
}
|
||||
if len(list) > 0 {
|
||||
args["from"] = list
|
||||
}
|
||||
}
|
||||
if v := get("to"); v != "" {
|
||||
args["to"] = v
|
||||
}
|
||||
if v := get("on-or-after"); v != "" {
|
||||
args["on-or-after"] = v
|
||||
}
|
||||
if v := get("before"); v != "" {
|
||||
args["before"] = v
|
||||
}
|
||||
if v := get("has-attachment"); v != "" {
|
||||
args["has-attachment"] = true
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// searchPage is issue 1265/AC1-AC4.
|
||||
func (b *Bundle) searchPage(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
data := searchTemplateData{
|
||||
Query: q.Get("query"), From: q.Get("from"), To: q.Get("to"),
|
||||
OnOrAfter: q.Get("on-or-after"), Before: q.Get("before"), HasAttachment: q.Get("has-attachment") != "",
|
||||
}
|
||||
args := searchArgsFromForm(q)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.searchAsk(ctx, args)
|
||||
if err != nil {
|
||||
data.Error = "the search could not be answered"
|
||||
renderPage(w, searchTemplate, data)
|
||||
return
|
||||
}
|
||||
var answer searchAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
data.Error = "the search could not be answered"
|
||||
renderPage(w, searchTemplate, data)
|
||||
return
|
||||
}
|
||||
data.Mails = answer.Found
|
||||
if len(answer.Found) >= MostFolderResults {
|
||||
last := answer.Found[len(answer.Found)-1].Date
|
||||
data.NextBefore = last
|
||||
next := r.URL.Query()
|
||||
next.Set("before", last)
|
||||
data.NextLink = "/search?" + next.Encode()
|
||||
}
|
||||
renderPage(w, searchTemplate, data)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
// The search page (novox/hq ADR 0440 §4, issue 1265).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1265/AC1: the search form filled with words, sender, recipient, a date range and has-attachment calls
|
||||
// mail-store.search with exactly query, from, to, on-or-after, before and has-attachment carrying those
|
||||
// values (limit is the page's own mechanical paging, not a form field).
|
||||
func TestSearchPageCallsSearchWithExactlyTheFilledFormFields(t *testing.T) {
|
||||
var gotArgs map[string]any
|
||||
b := &Bundle{Search: func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
gotArgs = args
|
||||
return json.Marshal(map[string]any{"found": []any{}})
|
||||
}}
|
||||
q := url.Values{
|
||||
"query": {"invoice"}, "from": {"ops@example.org lead@example.org"}, "to": {"me@example.org"},
|
||||
"on-or-after": {"2026-01-01"}, "before": {"2026-02-01"}, "has-attachment": {"on"},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/search?"+q.Encode(), nil)
|
||||
b.searchPage(w, r)
|
||||
|
||||
want := map[string]any{
|
||||
"query": "invoice", "from": []string{"ops@example.org", "lead@example.org"}, "to": "me@example.org",
|
||||
"on-or-after": "2026-01-01", "before": "2026-02-01", "has-attachment": true, "limit": float64(50),
|
||||
}
|
||||
if !reflect.DeepEqual(gotArgs, want) {
|
||||
t.Fatalf("args %#v, want %#v", gotArgs, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Unfilled fields are left out of the call entirely, never sent as "".
|
||||
func TestSearchPageLeavesEmptyFieldsOutOfTheCall(t *testing.T) {
|
||||
var gotArgs map[string]any
|
||||
b := &Bundle{Search: func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
gotArgs = args
|
||||
return json.Marshal(map[string]any{"found": []any{}})
|
||||
}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/search?query=hi", nil)
|
||||
b.searchPage(w, r)
|
||||
|
||||
want := map[string]any{"query": "hi", "limit": float64(50)}
|
||||
if !reflect.DeepEqual(gotArgs, want) {
|
||||
t.Fatalf("args %#v, want %#v", gotArgs, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 1265/AC2: more than 50 results shows 50 newest first, and the next link asks again with before the date of
|
||||
// the 50th shown.
|
||||
func TestSearchPageShows50AndLinksToTheNextPageByDate(t *testing.T) {
|
||||
var found []map[string]any
|
||||
for i := 0; i < 50; i++ {
|
||||
found = append(found, map[string]any{
|
||||
"account": "mesh", "folder": "INBOX", "uid": 200 - i, "subject": "s",
|
||||
"date": "2026-03-01T08:00:00Z", "from": []map[string]any{{"address": "x@example.org"}},
|
||||
})
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"found": found})
|
||||
b := &Bundle{Search: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/search?query=x", nil)
|
||||
b.searchPage(w, r)
|
||||
|
||||
body := strings.ToLower(w.Body.String())
|
||||
if !strings.Contains(body, "before=2026-03-01t08") {
|
||||
t.Fatalf("no next link with the 50th mail's date:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1265/AC3: words holding <script> show as text, and the page carries no script element.
|
||||
func TestSearchPageEscapesScriptInResults(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]any{"found": []map[string]any{
|
||||
{"account": "mesh", "folder": "INBOX", "uid": 1, "subject": "<script>alert(1)</script>",
|
||||
"date": "2026-01-01T00:00:00Z", "from": []map[string]any{{"address": "x@example.org"}}},
|
||||
}})
|
||||
b := &Bundle{Search: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/search?query=x", nil)
|
||||
b.searchPage(w, r)
|
||||
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "<script>") {
|
||||
t.Fatalf("the page carries a script element:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "<script>") {
|
||||
t.Fatalf("the subject is not shown escaped as text:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 1265/AC4: mail-store refuses or fails; the page says the search could not be answered, and nothing else
|
||||
// (no partial results, no error detail).
|
||||
func TestSearchPageSaysItCouldNotBeAnsweredOnFailure(t *testing.T) {
|
||||
b := &Bundle{Search: func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/search?query=x", nil)
|
||||
b.searchPage(w, r)
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "could not be answered") {
|
||||
t.Fatalf("the page does not say the search failed:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "<li>") {
|
||||
t.Fatalf("the page shows results despite the failure:\n%s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
// The web server (novox/hq ADR 0440 §2, §5; issue 1283): answers only its internal name mail.<node>.internal
|
||||
// (404 for any other Host — a public name, an IP, or none), sets a content security policy allowing only its
|
||||
// own origin on every answer, and writes nothing to disk. Pages are rendered through html/template only (its
|
||||
// auto-escaping), never written to a file. The seat verb open (issue 1262's claim) answers the page's address:
|
||||
// the root, or one mail's, by account, folder and uid (mail_read's own addressing, tools.go of mesh-mail).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// OpenTool is the mail-webclient seat's open verb.
|
||||
const OpenTool = "mail-webclient.open"
|
||||
|
||||
// CSP is ADR 0440 §5's own policy, set on every answer: nothing loaded from elsewhere, and this page never
|
||||
// framed by another site.
|
||||
const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; " +
|
||||
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||
|
||||
// InternalHost is this machine's own internal name for the module (ADR 0440 §2): the operator's own setting
|
||||
// (set on the machine, outside the manifest) assigns it the label `mail`, so it is always mail.<node>.internal.
|
||||
func InternalHost(node string) string { return "mail." + node + ".internal" }
|
||||
|
||||
// Server answers mesh-mail-web's HTTP endpoint: a Host check and a CSP on every answer (issue 1283), wrapping
|
||||
// a plain mux every page is added to, each behind the sign-in (RequireSession).
|
||||
type Server struct {
|
||||
Host string // InternalHost(node)
|
||||
Mux *http.ServeMux
|
||||
Auth *Auth // nil refuses no page by session (1283 alone); issue 1263 gives one
|
||||
Web *Bundle // nil answers only the stand-in root page (1283 alone); issue 1264 gives one
|
||||
}
|
||||
|
||||
// NewServer is a Server for host: with auth given, the sign-in callback (issue 1263) and every page behind a
|
||||
// session (RequireSession); with b also given, the real pages (accounts and folders, issue 1264; search,
|
||||
// issue 1265; a mail and its attachments, issue 1266; the write page and a send's state, issue 1267) in place
|
||||
// of 1283's own stand-in root.
|
||||
func NewServer(host string, auth *Auth, b *Bundle) *Server {
|
||||
s := &Server{Host: host, Mux: http.NewServeMux(), Auth: auth, Web: b}
|
||||
protect := func(h http.HandlerFunc) http.HandlerFunc {
|
||||
if auth != nil {
|
||||
return auth.RequireSession(h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
if auth != nil {
|
||||
s.Mux.HandleFunc(SignedInPath, auth.Callback)
|
||||
}
|
||||
if b != nil {
|
||||
s.Mux.HandleFunc("/", protect(b.accountsPage))
|
||||
s.Mux.HandleFunc("/folder", protect(b.folderPage))
|
||||
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))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ServeHTTP is issue 1283/AC1, AC2: every answer carries the CSP; a request for any Host but this server's own
|
||||
// internal name is 404, naming nothing of the mail.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", CSP)
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if host != s.Host {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.Mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// rootTemplate is 1283/AC4's own stand-in page: no account or mail content yet (issue 1264), only enough to
|
||||
// prove the server answers at all, through html/template (auto-escaping), never a file written to disk.
|
||||
var rootTemplate = template.Must(template.New("root").Parse(
|
||||
`<!DOCTYPE html><html><head><title>Mail</title></head><body><p>mesh-mail-web is up.</p></body></html>`))
|
||||
|
||||
func (s *Server) rootPage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = rootTemplate.Execute(w, nil)
|
||||
}
|
||||
|
||||
// openTool is issue 1262's claimed verb open, built here (1283) now that the server's own host exists: with
|
||||
// nothing given, the root; with account, folder and uid together, that mail's own address.
|
||||
func (b *Bundle) openTool(args map[string]any) (any, error) {
|
||||
if err := only(args, "account", "folder", "uid"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, _ := args["account"].(string)
|
||||
folder, _ := args["folder"].(string)
|
||||
uidArg, hasUID := args["uid"]
|
||||
|
||||
base := "https://" + InternalHost(b.Env.Node) + "/"
|
||||
if account == "" && folder == "" && !hasUID {
|
||||
return map[string]any{"url": base}, nil
|
||||
}
|
||||
uid, isNum := uidArg.(float64)
|
||||
if account == "" || folder == "" || !isNum || uid <= 0 {
|
||||
return nil, fmt.Errorf("%s needs account, folder and uid together, or none of them", OpenTool)
|
||||
}
|
||||
q := url.Values{"account": {account}, "folder": {folder}, "uid": {strconv.FormatFloat(uid, 'f', 0, 64)}}
|
||||
return map[string]any{"url": "https://" + InternalHost(b.Env.Node) + "/mail?" + q.Encode()}, nil
|
||||
}
|
||||
|
||||
// only refuses an argument the tool does not take (mirroring mesh-mail's own tools.go).
|
||||
func only(args map[string]any, takes ...string) error {
|
||||
for k := range args {
|
||||
ok := false
|
||||
for _, t := range takes {
|
||||
if k == t {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("the argument %q is not taken", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1283/AC1: a request whose Host is not mail.<node>.internal — a public name, an IP, or none — is answered
|
||||
// 404, naming nothing of the mail.
|
||||
func TestServerAnswers404ForAnyHostButItsOwnInternalName(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
for _, host := range []string{"example.com", "203.0.113.5", "", "mail.other-node.internal"} {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = host
|
||||
s.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("host %q: status %d, want 404", host, w.Code)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(w.Body.String()), "mail") {
|
||||
t.Fatalf("host %q: the 404 body names mail: %s", host, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Its own internal name is answered (not 404).
|
||||
func TestServerAnswersItsOwnInternalName(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A Host with a port still matches, port and all (the mesh's own listen carries one).
|
||||
func TestServerAnswersItsOwnInternalNameWithAPort(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = InternalHost("g14") + ":8099"
|
||||
s.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 1283/AC2: every answer (200 and 404 alike) carries the content security policy.
|
||||
func TestServerSetsTheContentSecurityPolicyOnEveryAnswer(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
cases := []string{"example.com", InternalHost("g14")}
|
||||
for _, host := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = host
|
||||
s.ServeHTTP(w, r)
|
||||
if got := w.Header().Get("Content-Security-Policy"); got != CSP {
|
||||
t.Fatalf("host %q: CSP %q, want %q", host, got, CSP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1283/AC3: open with nothing given answers the root; with account, folder and uid together, that mail's own
|
||||
// address.
|
||||
func TestOpenAnswersTheRootOrOneMailsAddress(t *testing.T) {
|
||||
b := &Bundle{Env: Env{Node: "g14"}}
|
||||
out, err := b.openTool(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out.(map[string]any)["url"]; got != "https://mail.g14.internal/" {
|
||||
t.Fatalf("root url: %v", got)
|
||||
}
|
||||
|
||||
out, err = b.openTool(map[string]any{"account": "mesh", "folder": "INBOX", "uid": float64(7)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
url := out.(map[string]any)["url"].(string)
|
||||
if !strings.HasPrefix(url, "https://mail.g14.internal/mail?") ||
|
||||
!strings.Contains(url, "account=mesh") || !strings.Contains(url, "folder=INBOX") || !strings.Contains(url, "uid=7") {
|
||||
t.Fatalf("mail url: %s", url)
|
||||
}
|
||||
|
||||
if _, err := b.openTool(map[string]any{"account": "mesh"}); err == nil {
|
||||
t.Fatalf("account alone, with no folder or uid, was not refused")
|
||||
}
|
||||
}
|
||||
|
||||
// 1283/AC4: with the working directory and home read-only, the server still starts and answers a page (it
|
||||
// writes nothing to disk).
|
||||
func TestServerStartsAndAnswersAPageWithACwdAndHomeReadOnly(t *testing.T) {
|
||||
ro := t.TempDir()
|
||||
if err := os.Chmod(ro, 0o500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(ro, 0o700) })
|
||||
|
||||
oldWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldHome := os.Getenv("HOME")
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(oldWd)
|
||||
_ = os.Setenv("HOME", oldHome)
|
||||
})
|
||||
if err := os.Chdir(ro); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Setenv("HOME", filepath.Join(ro, "home-does-not-exist"))
|
||||
|
||||
s := NewServer(InternalHost("g14"), nil, nil)
|
||||
srv := httptest.NewServer(s)
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Host = InternalHost("g14")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
// Sessions and the pending authorisations in flight (novox/hq ADR 0440 §3, issue 1263): both kept in memory
|
||||
// only, never on disk — a restart signs everyone out, accepted (design 59 milestone 1's prepare step).
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionCookie is the cookie's own name.
|
||||
const SessionCookie = "mesh_mail_web_session"
|
||||
|
||||
// SessionLifetime is how long a session lasts after it is made (novox/hq ADR 0440 §3, issue 1263/AC2, AC5).
|
||||
const SessionLifetime = 12 * time.Hour
|
||||
|
||||
// PendingLifetime is how long a sign-in may take between the redirect to the issuer and its callback, before
|
||||
// the state is no longer honoured.
|
||||
const PendingLifetime = 10 * time.Minute
|
||||
|
||||
func randomID() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
// Session is one signed-in operator's own session: its identity, when it was made (SessionLifetime decides
|
||||
// when it ends), and the per-session token issue 1284 binds every changing form to.
|
||||
type Session struct {
|
||||
Operator string
|
||||
Made time.Time
|
||||
Token string
|
||||
}
|
||||
|
||||
// Sessions keeps every session in memory, by its cookie's own id.
|
||||
type Sessions struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
byID map[string]Session
|
||||
}
|
||||
|
||||
// NewSessions is an empty Sessions; now is time.Now unless a test gives its own.
|
||||
func NewSessions(now func() time.Time) *Sessions {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Sessions{now: now, byID: map[string]Session{}}
|
||||
}
|
||||
|
||||
// Create starts a new session for operator, answering its id (the cookie's own value) and its own per-session
|
||||
// token (issue 1284).
|
||||
func (s *Sessions) Create(operator string) (id string, err error) {
|
||||
id, err = randomID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token, err := randomID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.byID[id] = Session{Operator: operator, Made: s.now(), Token: token}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Get is the session at id, or false when there is none or it ended SessionLifetime ago (1263/AC5): an expired
|
||||
// session reads as no session at all, dropped on the same look (no background sweep is needed: every read
|
||||
// already checks the time).
|
||||
func (s *Sessions) Get(id string) (Session, bool) {
|
||||
if id == "" {
|
||||
return Session{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.byID[id]
|
||||
if !ok {
|
||||
return Session{}, false
|
||||
}
|
||||
if s.now().Sub(sess.Made) > SessionLifetime {
|
||||
delete(s.byID, id)
|
||||
return Session{}, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
// PendingAuth is one sign-in asked of the issuer, remembered between the redirect and its callback: the PKCE
|
||||
// verifier the challenge was made from, and the page the browser first asked for.
|
||||
type PendingAuth struct {
|
||||
Verifier string
|
||||
Return string
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
// PendingAuths keeps every sign-in in flight, by its own state, in memory only.
|
||||
type PendingAuths struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
byState map[string]PendingAuth
|
||||
}
|
||||
|
||||
// NewPendingAuths is an empty PendingAuths; now is time.Now unless a test gives its own.
|
||||
func NewPendingAuths(now func() time.Time) *PendingAuths {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &PendingAuths{now: now, byState: map[string]PendingAuth{}}
|
||||
}
|
||||
|
||||
// Create remembers one sign-in in flight, answering the state it is kept under.
|
||||
func (p *PendingAuths) Create(verifier, returnTo string) (state string, err error) {
|
||||
state, err = randomID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.byState[state] = PendingAuth{Verifier: verifier, Return: returnTo, Created: p.now()}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// Take reads back and removes the sign-in kept under state — once; a state never issued, already taken, or
|
||||
// older than PendingLifetime answers false (1263/AC4's "a state it did not issue").
|
||||
func (p *PendingAuths) Take(state string) (PendingAuth, bool) {
|
||||
if state == "" {
|
||||
return PendingAuth{}, false
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
pa, ok := p.byState[state]
|
||||
if !ok {
|
||||
return PendingAuth{}, false
|
||||
}
|
||||
delete(p.byState, state)
|
||||
if p.now().Sub(pa.Created) > PendingLifetime {
|
||||
return PendingAuth{}, false
|
||||
}
|
||||
return pa, true
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
// The module's seat verbs (novox/hq ADR 0440 §8): mail-webclient.status (whether it serves, and which accounts
|
||||
// it is granted) and mail-webclient.open (issue 1283: the page's address). mail_web_health is its own health
|
||||
// tool, read-only, naming nothing of a page or an account.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Tool names, as the manifest declares them.
|
||||
const (
|
||||
StatusTool = "mail-webclient.status"
|
||||
HealthTool = "mail_web_health"
|
||||
)
|
||||
|
||||
// AccountsAsk is how the Bundle reaches seat:mail-store.accounts: the bus's own stdio.Ask, unless a test gives
|
||||
// its own fake (so a unit test needs no runtime).
|
||||
type AccountsAsk func(ctx context.Context) (json.RawMessage, error)
|
||||
|
||||
func sdkAccountsAsk(ctx context.Context) (json.RawMessage, error) {
|
||||
return stdio.Ask("seat:mail-store.accounts", map[string]any{})
|
||||
}
|
||||
|
||||
// SeatAsk is how the Bundle reaches one of mail-store's other verbs (search, read, send, send-status): the
|
||||
// bus's own stdio.Ask, unless a test gives its own fake.
|
||||
type SeatAsk func(ctx context.Context, args map[string]any) (json.RawMessage, error)
|
||||
|
||||
func sdkSeatAsk(verb string) SeatAsk {
|
||||
return func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
return stdio.Ask("seat:"+verb, args)
|
||||
}
|
||||
}
|
||||
|
||||
// AccountsBucketGet is how the Bundle reads mesh-mail's own account bucket (novox/hq ADR 0439 §2, issue 1255),
|
||||
// one account at a time (issue 1264/AC2: never Keys(), never an account mail-store did not itself answer).
|
||||
type AccountsBucketGet func(account string) (*stdio.StateEntry, error)
|
||||
|
||||
func sdkAccountsBucketGet(account string) (*stdio.StateEntry, error) {
|
||||
return stdio.State("mesh-mail.accounts").Get(account)
|
||||
}
|
||||
|
||||
// Bundle is what the tools reach: the module's own settings, and the seats it calls.
|
||||
type Bundle struct {
|
||||
Env Env
|
||||
Accounts AccountsAsk
|
||||
Search SeatAsk
|
||||
Read SeatAsk
|
||||
Send SeatAsk
|
||||
SendStatus SeatAsk
|
||||
AccountBucket AccountsBucketGet
|
||||
Now func() time.Time
|
||||
// HTTP is serveHTTP's own state (main.go, review round 1 of issue 1283, finding 4): nil answers
|
||||
// HTTPListening (every test that builds a Bundle directly, never through main's own serveHTTP, means this
|
||||
// module's HTTP endpoint whole).
|
||||
HTTP *HTTPStatus
|
||||
}
|
||||
|
||||
// httpPhase is b.HTTP.Get(), with no HTTP given read as HTTPListening (every unit test that builds a Bundle
|
||||
// directly rather than through main's own serveHTTP).
|
||||
func (b *Bundle) httpPhase() (phase string, err error) {
|
||||
if b.HTTP == nil {
|
||||
return HTTPListening, nil
|
||||
}
|
||||
return b.HTTP.Get()
|
||||
}
|
||||
|
||||
func (b *Bundle) accountsAsk(ctx context.Context) (json.RawMessage, error) {
|
||||
if b.Accounts != nil {
|
||||
return b.Accounts(ctx)
|
||||
}
|
||||
return sdkAccountsAsk(ctx)
|
||||
}
|
||||
|
||||
func (b *Bundle) searchAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.Search != nil {
|
||||
return b.Search(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-store.search")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) readAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.Read != nil {
|
||||
return b.Read(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-store.read")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) sendAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.Send != nil {
|
||||
return b.Send(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-store.send")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) sendStatusAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
if b.SendStatus != nil {
|
||||
return b.SendStatus(ctx, args)
|
||||
}
|
||||
return sdkSeatAsk("mail-store.send-status")(ctx, args)
|
||||
}
|
||||
|
||||
func (b *Bundle) accountBucketGet(account string) (*stdio.StateEntry, error) {
|
||||
if b.AccountBucket != nil {
|
||||
return b.AccountBucket(account)
|
||||
}
|
||||
return sdkAccountsBucketGet(account)
|
||||
}
|
||||
|
||||
func (b *Bundle) now() time.Time {
|
||||
if b.Now != nil {
|
||||
return b.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// Tools are the module's tools.
|
||||
func (b *Bundle) Tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: OpenTool,
|
||||
Description: "The mail web client's own address (novox/hq ADR 0440 §8): with nothing given, the " +
|
||||
"page's root; with account, folder and uid together (mail_read's own addressing), that mail's " +
|
||||
"own page. Read-only; answers a URL, never a page's content.",
|
||||
Input: schema(nil, map[string]any{
|
||||
"account": text("the account, by its name in mail_accounts"),
|
||||
"folder": text("the folder, such as INBOX"),
|
||||
"uid": integer("the message's uid in that folder"),
|
||||
}),
|
||||
Run: b.openTool},
|
||||
{Name: StatusTool,
|
||||
Description: "Whether mesh-mail-web serves, and the mail accounts it is granted through " +
|
||||
"seat:mail-store.accounts (novox/hq ADR 0440 §8). Read-only.",
|
||||
Input: schema(nil, map[string]any{}),
|
||||
Run: b.statusTool},
|
||||
{Name: HealthTool,
|
||||
Description: "How mesh-mail-web is: healthy once its own settings can be read, unhealthy naming why " +
|
||||
"otherwise. Read-only.",
|
||||
Input: schema(nil, map[string]any{}),
|
||||
Run: b.healthTool},
|
||||
}
|
||||
}
|
||||
|
||||
// statusTool is issue 1262/AC2, AC3: mail-store's own accounts answer, read as the granted accounts; an ask
|
||||
// mail-store does not answer, or refuses, answers "it does not serve" and why, never a crash. Finding 4 of
|
||||
// review round 1: it also says so whenever the HTTP endpoint itself is not actually listening (still setting
|
||||
// up sign-in, or ended on an error) — serving stdio's own seat verbs is not the same as serving mail-webclient
|
||||
// whole.
|
||||
func (b *Bundle) statusTool(map[string]any) (any, error) {
|
||||
if phase, httpErr := b.httpPhase(); phase != HTTPListening {
|
||||
return map[string]any{"serves": false, "why": httpStatusWhy(phase, httpErr)}, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
raw, err := b.accountsAsk(ctx)
|
||||
if err != nil {
|
||||
return map[string]any{"serves": false, "why": fmt.Sprintf("mail-store does not answer: %v", err)}, nil
|
||||
}
|
||||
var answer struct {
|
||||
Accounts []struct {
|
||||
Account string `json:"account"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
return map[string]any{"serves": false, "why": fmt.Sprintf("mail-store's answer could not be read: %v", err)}, nil
|
||||
}
|
||||
names := make([]string, 0, len(answer.Accounts))
|
||||
for _, a := range answer.Accounts {
|
||||
names = append(names, a.Account)
|
||||
}
|
||||
return map[string]any{"serves": true, "accounts": names}, nil
|
||||
}
|
||||
|
||||
// healthTool: finding 4 of review round 1 — unhealthy, naming the HTTP endpoint's own phase, whenever it is
|
||||
// not actually listening, beside its existing check that the operators setting can still be read.
|
||||
func (b *Bundle) healthTool(map[string]any) (any, error) {
|
||||
if phase, httpErr := b.httpPhase(); phase != HTTPListening {
|
||||
return map[string]any{"healthy": false, "why": httpStatusWhy(phase, httpErr)}, nil
|
||||
}
|
||||
if _, err := ReadOperators(b.Env.Operators); err != nil {
|
||||
return map[string]any{"healthy": false, "why": err.Error()}, nil
|
||||
}
|
||||
return map[string]any{"healthy": true}, nil
|
||||
}
|
||||
|
||||
// httpStatusWhy is the one sentence statusTool and healthTool alike say when the HTTP endpoint is not
|
||||
// HTTPListening: its own phase, and its error once it has one.
|
||||
func httpStatusWhy(phase string, err error) string {
|
||||
why := "its HTTP endpoint is " + phase
|
||||
if err != nil {
|
||||
why += fmt.Sprintf(": %v", err)
|
||||
}
|
||||
return why
|
||||
}
|
||||
|
||||
// ---- schema helpers (mirroring mesh-mail's tools.go) -----------------------------------------------------------
|
||||
|
||||
func schema(required []string, props map[string]any) map[string]any {
|
||||
s := map[string]any{"type": "object", "properties": props, "additionalProperties": false}
|
||||
if len(required) > 0 {
|
||||
s["required"] = required
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
func integer(d string) map[string]any { return map[string]any{"type": "integer", "description": d} }
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1262/AC2: mail-store answers accounts a and b; status answers that it serves, and is granted a and b.
|
||||
func TestStatusAnswersItServesAndTheGrantedAccounts(t *testing.T) {
|
||||
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"accounts": []map[string]any{
|
||||
{"account": "a", "kind": "imap"}, {"account": "b", "kind": "imap"},
|
||||
}})
|
||||
}}
|
||||
out, err := b.statusTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if m["serves"] != true {
|
||||
t.Fatalf("serves: %v", m["serves"])
|
||||
}
|
||||
got, _ := m["accounts"].([]string)
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("accounts: %v", m["accounts"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1262/AC3: mail-store does not answer (or refuses); status answers that it does not serve, and why, and does
|
||||
// not panic.
|
||||
func TestStatusAnswersItDoesNotServeWhenMailStoreRefuses(t *testing.T) {
|
||||
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) {
|
||||
return nil, errors.New("refused: mesh-mail-web is not granted any account")
|
||||
}}
|
||||
out, err := b.statusTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if m["serves"] != false {
|
||||
t.Fatalf("serves: %v", m["serves"])
|
||||
}
|
||||
why, _ := m["why"].(string)
|
||||
if why == "" {
|
||||
t.Fatalf("no reason given: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// Same AC3, the other half: mail-store answers something this module cannot read.
|
||||
func TestStatusAnswersItDoesNotServeWhenMailStoresAnswerCannotBeRead(t *testing.T) {
|
||||
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"accounts": "not a list"}`), nil
|
||||
}}
|
||||
out, err := b.statusTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if m["serves"] != false || m["why"] == "" {
|
||||
t.Fatalf("%v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// review round 1 finding 4: mail-webclient.status and mail_web_health both say the HTTP endpoint's own state
|
||||
// (main.go's serveHTTP) rather than going on claiming to serve and to be healthy regardless — one test per
|
||||
// state (setting up sign-in, listening, ended).
|
||||
func TestStatusAndHealthSayTheHTTPEndpointsState(t *testing.T) {
|
||||
accountsOK := func(context.Context) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"accounts": []any{}})
|
||||
}
|
||||
operatorsOK := operatorsFile(t, "alice")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
phase string
|
||||
err error
|
||||
}{
|
||||
{"setting up sign-in", HTTPSettingUpSignIn, errors.New("the issuer could not be reached")},
|
||||
{"ended", HTTPEnded, errors.New("listen tcp :8080: bind: address already in use")},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h := NewHTTPStatus()
|
||||
h.set(c.phase, c.err)
|
||||
b := &Bundle{HTTP: h, Accounts: accountsOK, Env: Env{Operators: operatorsOK}}
|
||||
|
||||
statusOut, err := b.statusTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sm := statusOut.(map[string]any)
|
||||
if sm["serves"] != false {
|
||||
t.Fatalf("status serves: %v, want false while %s", sm["serves"], c.phase)
|
||||
}
|
||||
if why, _ := sm["why"].(string); !strings.Contains(why, c.phase) {
|
||||
t.Fatalf("status why %q does not name the phase %q", why, c.phase)
|
||||
}
|
||||
|
||||
healthOut, err := b.healthTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hm := healthOut.(map[string]any)
|
||||
if hm["healthy"] != false {
|
||||
t.Fatalf("health healthy: %v, want false while %s", hm["healthy"], c.phase)
|
||||
}
|
||||
if why, _ := hm["why"].(string); !strings.Contains(why, c.phase) {
|
||||
t.Fatalf("health why %q does not name the phase %q", why, c.phase)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// HTTPListening: status and health go back to their own, ordinary checks (mail-store's own accounts
|
||||
// answer, the operators setting), neither blocked by the HTTP endpoint's own state any more.
|
||||
t.Run("listening", func(t *testing.T) {
|
||||
h := NewHTTPStatus()
|
||||
h.set(HTTPListening, nil)
|
||||
b := &Bundle{HTTP: h, Accounts: accountsOK, Env: Env{Operators: operatorsOK}}
|
||||
|
||||
statusOut, err := b.statusTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sm := statusOut.(map[string]any); sm["serves"] != true {
|
||||
t.Fatalf("status serves: %v, want true once listening", sm["serves"])
|
||||
}
|
||||
|
||||
healthOut, err := b.healthTool(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hm := healthOut.(map[string]any); hm["healthy"] != true {
|
||||
t.Fatalf("health healthy: %v, want true once listening: %v", hm["healthy"], hm)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"client-id": "${bound:oidc-client:as}",
|
||||
"client-secret": "${secret:oidc-client}",
|
||||
"issuer": "${bound:oidc-client:issuer}"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"operators": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
module mesh-mail-web
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/oauth2 v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c h1:/f6f2ugss172hDJSL3EFZqb9PdfO5DKzYVfPiX2LdAM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"module": "mesh-mail-web",
|
||||
"version": "1",
|
||||
"slug": "mail-web",
|
||||
"runs-as": "mesh-mail-web",
|
||||
"claims": [
|
||||
{
|
||||
"name": "mail-webclient",
|
||||
"scope": "mesh",
|
||||
"serves": ["open", "status"]
|
||||
}
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mail-store.accounts",
|
||||
"seat:mail-store.search",
|
||||
"seat:mail-store.read",
|
||||
"seat:mail-store.send",
|
||||
"seat:mail-store.send-status"
|
||||
],
|
||||
"reads": ["mesh-mail.accounts"],
|
||||
"requires": ["route", "oidc-client"],
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "mail",
|
||||
"endpoint": "web"
|
||||
},
|
||||
"oidc-client": {
|
||||
"label": "mail",
|
||||
"endpoint": "web",
|
||||
"callback": "/signed-in"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "${dir:state}/route.json",
|
||||
"oidc-client": "${dir:state}/oidc-bound.json"
|
||||
},
|
||||
"secrets": {
|
||||
"oidc-client": "${dir:state}/oidc-bound.secret"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8099,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the mail web client's pages, over http; only the route proxy reaches it, and the operator's own browser through the internal name (novox/hq ADR 0440 §2) — its public name, if any, is a route grant"
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"operators": {
|
||||
"type": "list",
|
||||
"description": "The Keycloak identities (the ID token's preferred_username) let to sign in; nobody by default (novox/hq ADR 0440 §3).",
|
||||
"trusted": true,
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
"mail-webclient.open",
|
||||
"mail-webclient.status",
|
||||
"mail_web_health"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-mail-web",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-mail-web",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-mail-web"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-mail-web",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "operators",
|
||||
"trusted": true,
|
||||
"type": "file",
|
||||
"path": "${dir:state}/operators.json",
|
||||
"mode": "0600",
|
||||
"owner": "mesh-mail-web",
|
||||
"merge": "json",
|
||||
"template": "files/operators.json",
|
||||
"takes-config": ["operators"]
|
||||
},
|
||||
{
|
||||
"id": "oidc-config",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/oidc.json",
|
||||
"mode": "0600",
|
||||
"owner": "mesh-mail-web",
|
||||
"template": "files/oidc.json"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-mail-web",
|
||||
"binary": "mesh-mail-web",
|
||||
"loads": ["mesh-mail-web"],
|
||||
"env": {
|
||||
"MESH_MAIL_WEB_OPERATORS": "${dir:state}/operators.json",
|
||||
"MESH_MAIL_WEB_OIDC": "${dir:state}/oidc.json",
|
||||
"MESH_MAIL_WEB_LISTEN": "${port:8099}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ The password of an account is the own secret `mail-credential-<account>`, given
|
||||
| Tool | Answers |
|
||||
|---|---|
|
||||
| `mail_accounts` | each account's kind, state and why, when its server last answered, its folders; the address only to a caller granted it |
|
||||
| `mail_search` | messages of the accounts the caller is granted, by every word of a query, newest first, no body |
|
||||
| `mail_search` | messages of the accounts the caller is granted, no body: optional query words, and every filter combined (account, folder, from/to/cc, a date range, subject/body words, attachment, read/unread, in-reply-to, from-someone-i-wrote-to); ordered by date (default), sender, subject or size, ascending or descending; group-by-sender answers one line per sender instead. The newest mails of the granted accounts with no filter at all |
|
||||
| `mail_read` | one message's metadata and text, or one attachment's bytes, to a caller granted its account |
|
||||
| `mail_health` | healthy; unhealthy naming the account and why (connection down, or no answer for five minutes); waits naming `mail-credential-<account>` while its password is not given |
|
||||
| `mail_send` | raises an ask on the operator channel (the account, the recipients, the subject, the first lines) for a caller granted the account; answers at once with the ask's id. Sends nothing itself |
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// Release 3's acceptance test (novox/hq ADR 0436, issue 1198 AC4): the whole module, over a JetStream bus
|
||||
// of the test's own (nats-server v2.11.17, copied from modules/mesh-delivery/cmd/mesh-delivery/bus_test.go
|
||||
// as newTestBus already does in sender_integration_test.go), a fake SMTP server, the router's records
|
||||
// bucket with a closed Approve record, the account file and a password file written by the test. Compiled
|
||||
// by the merge gate's `go vet -tags integration`; never run on this machine (the context file's hard rule).
|
||||
//
|
||||
// "The tools queue an approved send" is played here directly against the real bus (Decide, send.go) rather
|
||||
// than through the tool runner's own RPC — the tool runner's part (stdio.Serve, grants, the ask's wording)
|
||||
// is proved elsewhere (send_test.go, ask_test.go); what this test proves is the handover from an approved,
|
||||
// queued send through to the sender's own claim, send and outcome, end to end over a real bus.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// kvSends adapts a raw jetstream.KeyValue to SendStore (send.go): the test's own stand-in for both the
|
||||
// tools' and the sender's `sends` state, which in production are the same bucket reached under two
|
||||
// different accounts.
|
||||
type kvSends struct{ kv jetstream.KeyValue }
|
||||
|
||||
func (k kvSends) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
e, err := k.kv.Get(context.Background(), key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return json.RawMessage(e.Value()), e.Revision(), true, nil
|
||||
}
|
||||
func (k kvSends) Create(key string, value any) (uint64, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rev, err := k.kv.Create(context.Background(), key, data)
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return 0, stdio.ErrChanged
|
||||
}
|
||||
return rev, err
|
||||
}
|
||||
func (k kvSends) Update(key string, value any, revision uint64) (uint64, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rev, err := k.kv.Update(context.Background(), key, data, revision)
|
||||
if errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
|
||||
return 0, stdio.ErrChanged
|
||||
}
|
||||
return rev, err
|
||||
}
|
||||
func (k kvSends) Keys() ([]string, error) { return nil, errors.New("not used by this test") }
|
||||
|
||||
// acceptanceBus is everything the test built on a fresh bus: ready-made buckets and streams, and the raw
|
||||
// clients to act as the tools with.
|
||||
type acceptanceBus struct {
|
||||
nc *nats.Conn
|
||||
js jetstream.JetStream
|
||||
sends jetstream.KeyValue
|
||||
asks jetstream.KeyValue
|
||||
}
|
||||
|
||||
func newAcceptanceBus(t *testing.T) (*acceptanceBus, string) {
|
||||
t.Helper()
|
||||
url := newTestBus(t)
|
||||
ctx := context.Background()
|
||||
nc, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sends, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: Module + "_" + SendsState})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asksKV, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: MessengerAsksBucket})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.CreateStream(ctx, mailSendsStreamConfig()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod." + Module + ".event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &acceptanceBus{nc: nc, js: js, sends: sends, asks: asksKV}, url
|
||||
}
|
||||
|
||||
// queueAnApprovedSend plays the tools' own part (send.go's Decide, the queue publish) directly against the
|
||||
// real bus: a send asked, approved by a warrant whose digest really is the one req.Ask() binds, recorded
|
||||
// approved and queued — exactly what issue 1194 made the tools do, without needing the tool runner's RPC.
|
||||
func queueAnApprovedSend(t *testing.T, bus *acceptanceBus, account string, approvedAt time.Time) (SendRequest, string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
req := SendRequest{ID: newSendID(), Account: account, To: []string{"ops@example.org"}, Subject: "The nightly gate is red",
|
||||
Body: "See the job log.", Caller: "person.op", At: approvedAt, AskTo: "o… at example org"}
|
||||
store := kvSends{kv: bus.sends}
|
||||
if _, err := store.Create(req.ID, req); err != nil {
|
||||
t.Fatalf("keeping the send: %v", err)
|
||||
}
|
||||
w := warrantFor(req, approvedAt)
|
||||
rec := routerRecord{State: string(w.Outcome), Warrant: &w}
|
||||
raw, _ := json.Marshal(rec)
|
||||
if _, err := bus.asks.Put(ctx, Module+"."+req.ID, raw); err != nil {
|
||||
t.Fatalf("writing the router's record: %v", err)
|
||||
}
|
||||
// The tools' own publish path (send.go's QueuePublish, wired for real to stdio.Emit/sdkQueuePublish):
|
||||
// the real wire subject the controller composes for mail-sends' one event (mailSendApprovedSubject),
|
||||
// the send id as the message id, so the stream's own duplicate window is the one actually exercised
|
||||
// (novox/hq review round 1, finding 7).
|
||||
publish := func(id string, body any) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = bus.js.Publish(ctx, mailSendApprovedSubject, data, jetstream.WithMsgID(id))
|
||||
return err
|
||||
}
|
||||
if _, err := Decide(store, publish, w, approvedAt); err != nil {
|
||||
t.Fatalf("the tools' own Decide: %v", err)
|
||||
}
|
||||
kept, _, ok, err := getSend(store, req.ID)
|
||||
if err != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "queued" {
|
||||
t.Fatalf("not left queued: %v %v %+v", err, ok, kept.Decided)
|
||||
}
|
||||
// Published a second time under the same message id, exactly as the sweep would for a record it finds
|
||||
// still queued past its grace (Sweep, send.go): the stream's 24-hour duplicate window keeps it one
|
||||
// message (novox/hq review round 1, finding 7) — the sender's own claim (clause 9) is what then keeps
|
||||
// it one send, proved by this test's own "sent once" assertion below.
|
||||
if err := publish(req.ID, QueuedSend{Send: req.ID, Account: req.Account, Ask: req.ID}); err != nil {
|
||||
t.Fatalf("the duplicate publish: %v", err)
|
||||
}
|
||||
return req, req.ID
|
||||
}
|
||||
|
||||
// 1198/AC4: when the tools queue an approved send and the send process runs, the fake server receives
|
||||
// exactly one mail carrying the send id's Message-ID, sends says sent, and mail.send.sent is on the stream.
|
||||
func TestMailSenderAcceptanceAnApprovedSendIsSentOnceAndSaidOnce(t *testing.T) {
|
||||
bus, url := newAcceptanceBus(t)
|
||||
fake := newFakeSMTPServer(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
accountsPath := filepath.Join(dir, "accounts.json")
|
||||
writeFile(t, accountsPath, `{"accounts": {"mesh": {"kind": "imap", "address": "jschoubben@novox.be",
|
||||
"incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"},
|
||||
"outgoing": {"host": "127.0.0.1", "port": 1, "security": "tls"}}}}`)
|
||||
writeFile(t, filepath.Join(dir, "mail-credential-mesh.secret"), "the-password\n")
|
||||
|
||||
approvedAt := judgedAt
|
||||
req, id := queueAnApprovedSend(t, bus, "mesh", approvedAt)
|
||||
|
||||
account := process.Account{URL: url, Node: "g14", Module: Module, User: "g14." + Module + "." + SendProcessID, Password: "pw"}
|
||||
if _, err := bus.js.CreateConsumer(context.Background(), MailSendsStream, jetstream.ConsumerConfig{
|
||||
Durable: account.QueueConsumerName(), AckPolicy: jetstream.AckExplicitPolicy, AckWait: 5 * time.Second, MaxDeliver: MostAttempts}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, err := process.Connect(account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
h := &senderHandler{
|
||||
Store: kvSends{kv: bus.sends},
|
||||
Record: routerRecordOf(conn),
|
||||
Accounts: func() ([]Account, error) { return ReadAccounts(accountsPath) },
|
||||
SecretsDir: dir,
|
||||
Dial: fake.dialer(),
|
||||
Emit: emitOf(conn),
|
||||
Now: func() time.Time { return approvedAt.Add(time.Minute) },
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Consume(ctx, MailSendsStream, h.handle) }()
|
||||
|
||||
eventually(t, 10*time.Second, "the mail sent", func() (bool, string) {
|
||||
return fake.count() == 1, "received so far: " + describeReceived(fake)
|
||||
})
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
store := kvSends{kv: bus.sends}
|
||||
kept, _, ok, err := getSend(store, id)
|
||||
if err != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "sent" {
|
||||
t.Fatalf("the kept send: %v %v %+v", err, ok, kept.Decided)
|
||||
}
|
||||
got := fake.last()
|
||||
if got.to[0] != "ops@example.org" {
|
||||
t.Fatalf("received: %+v", got)
|
||||
}
|
||||
if !strings.Contains(got.data, "Message-Id: <"+req.ID+"@novox.be>") {
|
||||
t.Fatalf("the mail's Message-ID does not name the send: %s", got.data)
|
||||
}
|
||||
|
||||
// mail.send.sent is on the EVENTS stream, naming the send id, never the recipient, subject or body.
|
||||
eventually(t, 10*time.Second, "mail.send.sent on the stream", func() (bool, string) {
|
||||
msg, err := fetchOneRaw(bus, "mesh.mod."+Module+".event."+MailSendSent)
|
||||
if err != nil || msg == nil {
|
||||
return false, "not yet"
|
||||
}
|
||||
var e OutcomeEvent
|
||||
_ = json.Unmarshal(msg, &e)
|
||||
return e.Send == req.ID && e.Account == "mesh", "got " + string(msg)
|
||||
})
|
||||
}
|
||||
|
||||
// 1198/AC4: when the fake server refuses sign-in, then after the attempts (shortened here) the record says
|
||||
// failed and nothing was received.
|
||||
func TestMailSenderAcceptanceARefusedSignInFailsAfterItsAttemptsAndNothingIsReceived(t *testing.T) {
|
||||
orig := RetryDelays
|
||||
RetryDelays = []time.Duration{10 * time.Millisecond, 10 * time.Millisecond, 10 * time.Millisecond, 10 * time.Millisecond}
|
||||
t.Cleanup(func() { RetryDelays = orig })
|
||||
|
||||
bus, url := newAcceptanceBus(t)
|
||||
s := newScriptedSMTPServer(t)
|
||||
s.auth = reply(535, "authentication failed")
|
||||
|
||||
dir := t.TempDir()
|
||||
accountsPath := filepath.Join(dir, "accounts.json")
|
||||
writeFile(t, accountsPath, `{"accounts": {"mesh": {"kind": "imap", "address": "jschoubben@novox.be",
|
||||
"incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"},
|
||||
"outgoing": {"host": "127.0.0.1", "port": 1, "security": "tls"}}}}`)
|
||||
writeFile(t, filepath.Join(dir, "mail-credential-mesh.secret"), "the-wrong-password\n")
|
||||
|
||||
approvedAt := judgedAt
|
||||
_, id := queueAnApprovedSend(t, bus, "mesh", approvedAt)
|
||||
|
||||
account := process.Account{URL: url, Node: "g14", Module: Module, User: "g14." + Module + "." + SendProcessID, Password: "pw"}
|
||||
if _, err := bus.js.CreateConsumer(context.Background(), MailSendsStream, jetstream.ConsumerConfig{
|
||||
Durable: account.QueueConsumerName(), AckPolicy: jetstream.AckExplicitPolicy, AckWait: 2 * time.Second, MaxDeliver: MostAttempts}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, err := process.Connect(account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
h := &senderHandler{
|
||||
Store: kvSends{kv: bus.sends},
|
||||
Record: routerRecordOf(conn),
|
||||
Accounts: func() ([]Account, error) { return ReadAccounts(accountsPath) },
|
||||
SecretsDir: dir,
|
||||
Dial: s.dialer(),
|
||||
Emit: emitOf(conn),
|
||||
Now: func() time.Time { return approvedAt.Add(time.Minute) },
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Consume(ctx, MailSendsStream, h.handle) }()
|
||||
|
||||
store := kvSends{kv: bus.sends}
|
||||
eventually(t, 25*time.Second, "the send to fail after its attempts", func() (bool, string) {
|
||||
kept, _, ok, err := getSend(store, id)
|
||||
if err != nil || !ok || kept.Decided == nil {
|
||||
return false, "not yet decided"
|
||||
}
|
||||
// "queued" is the send's own starting state (queueAnApprovedSend's own write, before the sender
|
||||
// ever claims it): without excluding it here too, this check reads done on the very first look,
|
||||
// long before the sender connects or claims anything at all (novox/hq review: build-1792400882114092431,
|
||||
// 1198/AC4).
|
||||
o := kept.Decided.Outcome
|
||||
return o != "queued" && o != "retrying" && o != "sending", o
|
||||
})
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
kept, _, ok, err := getSend(store, id)
|
||||
if err != nil || !ok || kept.Decided == nil || !strings.HasPrefix(kept.Decided.Outcome, "failed: ") {
|
||||
t.Fatalf("the kept send did not end failed: %v %v %+v", err, ok, kept.Decided)
|
||||
}
|
||||
if kept.Decided.Attempt != MostAttempts {
|
||||
t.Fatalf("failed after %d attempt(s), want all %d", kept.Decided.Attempt, MostAttempts)
|
||||
}
|
||||
if n := s.receivedCount(); n != 0 {
|
||||
t.Fatalf("the scripted server received %d message(s), want none: a refused sign-in never reaches DATA", n)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchOneRaw pulls at most one message off a stream's subject, or nil within a short wait, for a test to
|
||||
// check an emitted event without binding a durable consumer of its own.
|
||||
func fetchOneRaw(bus *acceptanceBus, subject string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
cons, err := bus.js.OrderedConsumer(ctx, "EVENTS", jetstream.OrderedConsumerConfig{FilterSubjects: []string{subject}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
batch, err := cons.Fetch(1, jetstream.FetchMaxWait(time.Second))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg, ok := <-batch.Messages()
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return msg.Data(), nil
|
||||
}
|
||||
|
||||
func describeReceived(f *fakeSMTPServer) string {
|
||||
if f.count() == 0 {
|
||||
return "none"
|
||||
}
|
||||
got := f.last()
|
||||
return got.from
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
package main
|
||||
|
||||
// The sender's own claim (novox/hq ADR 0436 clauses 9, 10, issue 1196): it never trusts the queue's message,
|
||||
// verifying the operator's warrant again from the router's own record before it claims the send by
|
||||
// compare-and-set — exactly mirroring the tools' own check, so the boundary clause 9 describes (the tool
|
||||
// runner's account may publish on the queue and write `sends`, but only a warrant that really closed the
|
||||
// ask moves a send to "sending").
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// claimTestSend asks and approves one send (through the tools, as issue 1194 built it), leaving it queued,
|
||||
// and returns its id and the router that holds its warrant.
|
||||
func claimTestSend(t *testing.T) (id string, router *sendRouter, sends *memSends) {
|
||||
t.Helper()
|
||||
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
queue := newMemQueue()
|
||||
b.Queue = queue.publish
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id = out["ask"].(string)
|
||||
|
||||
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
|
||||
router.close(w)
|
||||
if _, err := Decide(sends, b.queuePublish(), w, judgedAt.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("approving: %v", err)
|
||||
}
|
||||
req, _, ok, err := getSend(sends, id)
|
||||
if err != nil || !ok || req.Decided == nil || req.Decided.Outcome != "queued" {
|
||||
t.Fatalf("not left queued: %v %v %+v", err, ok, req.Decided)
|
||||
}
|
||||
return id, router, sends
|
||||
}
|
||||
|
||||
// rawQueuedSend creates a send directly in the queued state, without going through the tools or the router
|
||||
// at all, so an AC2 test controls the router's record independently of how the send reached queued: the
|
||||
// defence in depth clause 9 describes, since the tool runner's account could write `sends` and the queue
|
||||
// without ever having closed a real warrant.
|
||||
func rawQueuedSend(t *testing.T, sends *memSends) SendRequest {
|
||||
t.Helper()
|
||||
req := SendRequest{ID: newSendID(), Account: "mesh", To: []string{"ops@example.org"}, Subject: "Status",
|
||||
Body: "the body", Caller: "person.op", At: judgedAt, AskTo: "o… at example org"}
|
||||
req.Decided = &SendDecision{At: judgedAt.Add(time.Minute), Outcome: "queued"}
|
||||
if _, err := sends.Create(req.ID, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// warrantFor is a well-formed warrant answering req's ask with Send, at, and the ask's own digest — the
|
||||
// same shape sendRouter.chose builds, for a test that never published the ask to a router at all.
|
||||
func warrantFor(req SendRequest, at time.Time) asks.Warrant {
|
||||
ask := req.Ask()
|
||||
opt, _ := ask.Option(OptionSend)
|
||||
return asks.Warrant{Ask: req.ID, Asker: Module, Outcome: asks.OutcomeChosen, Option: opt.ID, Label: opt.Label,
|
||||
Level: opt.Level,
|
||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
||||
Channel: "telegram", Proofs: []string{"P1"}, At: at, AskDigest: ask.Digest()}
|
||||
}
|
||||
|
||||
// 1196/AC1: given a queued send whose router record is closed by the operator's Approve at level approve
|
||||
// within its expiry and whose recomputed act digest is the option's binds, when the sender takes the
|
||||
// message, then it claims the send by compare-and-set from queued to sending with the attempt and time
|
||||
// before any connection (nothing here connects to anything: claimQueuedSend never dials).
|
||||
func TestClaimQueuedSendClaimsSendingWithTheAttemptBeforeAnyConnection(t *testing.T) {
|
||||
id, router, sends := claimTestSend(t)
|
||||
|
||||
emit := &memEmit{}
|
||||
req, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, id, judgedAt.Add(3*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("claiming: %v", err)
|
||||
}
|
||||
if outcome != claimToSend {
|
||||
t.Fatalf("outcome: %v, want claimToSend", outcome)
|
||||
}
|
||||
if d.Outcome != "sending" || d.Attempt != 1 || !d.At.Equal(judgedAt.Add(3*time.Minute)) {
|
||||
t.Fatalf("claimed: %+v", d)
|
||||
}
|
||||
if req.ID != id {
|
||||
t.Fatalf("req: %+v", req)
|
||||
}
|
||||
kept, _, ok, err := getSend(sends, id)
|
||||
if err != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "sending" {
|
||||
t.Fatalf("not kept as sending: %v %v %+v", err, ok, kept.Decided)
|
||||
}
|
||||
if len(emit.calls) != 0 {
|
||||
t.Fatalf("claiming to send emitted something: %+v", emit.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1196/AC2: given a router record that is not mesh-mail's, not closed by Approve, approved after its
|
||||
// expiry, or whose digest differs from the request in sends, when taken, then the sender records refused
|
||||
// with the reason, terminates the message and connects to no server (claimQueuedSend never dials, so
|
||||
// "connects to no server" holds for every path by construction; this proves each is refused and the claim
|
||||
// never advances to sending).
|
||||
func TestClaimQueuedSendRefusesAMismatchedOrUnclosedWarrant(t *testing.T) {
|
||||
t.Run("not mesh-mail's ask", func(t *testing.T) {
|
||||
sends, router := newMemSends(), newSendRouter()
|
||||
req := rawQueuedSend(t, sends)
|
||||
w := warrantFor(req, judgedAt.Add(time.Minute))
|
||||
w.Asker = "another-module"
|
||||
router.close(w)
|
||||
emit := &memEmit{}
|
||||
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
|
||||
if err != nil || outcome != claimRefused || !strings.HasPrefix(d.Outcome, "refused: ") {
|
||||
t.Fatalf("outcome %v, decision %+v, err %v", outcome, d, err)
|
||||
}
|
||||
assertEmittedFailed(t, emit, req.ID, d.Outcome)
|
||||
})
|
||||
t.Run("not closed by approve", func(t *testing.T) {
|
||||
sends, router := newMemSends(), newSendRouter()
|
||||
req := rawQueuedSend(t, sends)
|
||||
// The router keeps no record of this ask at all: never published, or never answered.
|
||||
emit := &memEmit{}
|
||||
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
|
||||
if err != nil || outcome != claimRefused || !strings.HasPrefix(d.Outcome, "refused: ") {
|
||||
t.Fatalf("outcome %v, decision %+v, err %v", outcome, d, err)
|
||||
}
|
||||
assertEmittedFailed(t, emit, req.ID, d.Outcome)
|
||||
})
|
||||
t.Run("approved after the ask's expiry", func(t *testing.T) {
|
||||
sends, router := newMemSends(), newSendRouter()
|
||||
req := rawQueuedSend(t, sends)
|
||||
w := warrantFor(req, req.Ask().Expires.Add(time.Hour))
|
||||
router.close(w)
|
||||
emit := &memEmit{}
|
||||
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
|
||||
if err != nil || outcome != claimRefused || !strings.HasPrefix(d.Outcome, "refused: ") {
|
||||
t.Fatalf("outcome %v, decision %+v, err %v", outcome, d, err)
|
||||
}
|
||||
assertEmittedFailed(t, emit, req.ID, d.Outcome)
|
||||
})
|
||||
t.Run("a digest the published ask never bound", func(t *testing.T) {
|
||||
sends, router := newMemSends(), newSendRouter()
|
||||
req := rawQueuedSend(t, sends)
|
||||
w := warrantFor(req, judgedAt.Add(time.Minute))
|
||||
w.AskDigest = "sha256:" + strings.Repeat("0", 64)
|
||||
router.close(w)
|
||||
emit := &memEmit{}
|
||||
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
|
||||
if err != nil || outcome != claimRefused || !strings.HasPrefix(d.Outcome, "refused: ") {
|
||||
t.Fatalf("outcome %v, decision %+v, err %v", outcome, d, err)
|
||||
}
|
||||
assertEmittedFailed(t, emit, req.ID, d.Outcome)
|
||||
})
|
||||
}
|
||||
|
||||
// assertEmittedFailed checks that exactly one mail.send.failed was emitted, naming the send id and the
|
||||
// decision's own outcome in words (novox/hq review round 1 finding 4).
|
||||
func assertEmittedFailed(t *testing.T, emit *memEmit, id, outcome string) {
|
||||
t.Helper()
|
||||
if len(emit.calls) != 1 {
|
||||
t.Fatalf("emitted %d time(s), want exactly one mail.send.failed: %+v", len(emit.calls), emit.calls)
|
||||
}
|
||||
c := emit.calls[0]
|
||||
if c.eventType != MailSendFailed || c.id != id || c.body.Send != id || c.body.Outcome != outcome {
|
||||
t.Fatalf("emitted %+v, want mail.send.failed naming %s and outcome %q", c, id, outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// 1196/AC3: given a send already sending (the sender stopped mid-send), when its message is delivered
|
||||
// again, then the sender sends nothing, records not proven and terminates.
|
||||
func TestClaimQueuedSendOnARedeliveryOfAnAlreadySendingClaimRecordsNotProven(t *testing.T) {
|
||||
id, router, sends := claimTestSend(t)
|
||||
emit := &memEmit{}
|
||||
req, _, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, id, judgedAt.Add(3*time.Minute))
|
||||
if err != nil || outcome != claimToSend {
|
||||
t.Fatalf("first claim: %v %v", outcome, err)
|
||||
}
|
||||
_ = req
|
||||
|
||||
// Redelivered: the first claim's handler may have stopped mid-send.
|
||||
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, id, judgedAt.Add(4*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("redelivered claim: %v", err)
|
||||
}
|
||||
if outcome != claimNotProven || d.Outcome != "not proven" {
|
||||
t.Fatalf("outcome %v, decision %+v", outcome, d)
|
||||
}
|
||||
assertEmittedFailed(t, emit, id, d.Outcome)
|
||||
}
|
||||
|
||||
// 1196/AC4: given a lost compare-and-set (another claim), when taken, then it sends nothing and leaves the
|
||||
// message to the bus (no decision of this call's own making is kept; it reads back whatever the other
|
||||
// writer decided).
|
||||
func TestClaimQueuedSendOnALostRaceSendsNothingAndLeavesTheMessageToTheBus(t *testing.T) {
|
||||
id, router, sends := claimTestSend(t)
|
||||
racy := &raceOnFirstUpdate{SendStore: sends, race: func() {
|
||||
r, rv, ok, err := getSend(sends, id)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("racing claim could not read the send: %v %v", err, ok)
|
||||
}
|
||||
if _, _, err := settleSend(sends, r, rv, SendDecision{At: judgedAt.Add(3 * time.Minute), Outcome: "sending", Attempt: 1}); err != nil {
|
||||
t.Fatalf("racing claim: %v", err)
|
||||
}
|
||||
}}
|
||||
|
||||
emit := &memEmit{}
|
||||
_, d, _, outcome, err := claimQueuedSend(racy, router.Record, emit.emit, id, judgedAt.Add(3*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("claiming: %v", err)
|
||||
}
|
||||
if outcome != claimLostRace {
|
||||
t.Fatalf("outcome: %v, want claimLostRace", outcome)
|
||||
}
|
||||
if d.Outcome != "sending" || d.Attempt != 1 {
|
||||
t.Fatalf("the decision read back is not the racing claim's own: %+v", d)
|
||||
}
|
||||
if len(emit.calls) != 0 {
|
||||
t.Fatalf("a lost race emitted something: %+v", emit.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// review round 1 finding 3: a failed write of the tells-pending index does not stop the refusal itself
|
||||
// from being recorded — the record write and the message's own fate are never undone by an index that
|
||||
// could not be kept — but the index failure is still reported, never silently dropped.
|
||||
func TestClaimQueuedSendStillRefusesWhenTheTellsPendingIndexCannotBeWritten(t *testing.T) {
|
||||
sends, router := newMemSends(), newSendRouter()
|
||||
req := rawQueuedSend(t, sends)
|
||||
sends.failKey, sends.failErr = tellsPendingKey, errors.New("the store is down")
|
||||
|
||||
emit := &memEmit{}
|
||||
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
|
||||
if outcome != claimRefused || !strings.HasPrefix(d.Outcome, "refused: ") {
|
||||
t.Fatalf("outcome %v, decision %+v", outcome, d)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("the index failure was not reported")
|
||||
}
|
||||
kept, _, ok, kerr := getSend(sends, req.ID)
|
||||
if kerr != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != d.Outcome {
|
||||
t.Fatalf("the refusal itself was not kept despite the index failing: %v %v %+v", kerr, ok, kept.Decided)
|
||||
}
|
||||
assertEmittedFailed(t, emit, req.ID, d.Outcome)
|
||||
}
|
||||
|
||||
// Same finding, for the redelivery-finds-sending path (not proven).
|
||||
func TestClaimQueuedSendStillRecordsNotProvenWhenTheTellsPendingIndexCannotBeWritten(t *testing.T) {
|
||||
sends, router := newMemSends(), newSendRouter()
|
||||
req := rawQueuedSend(t, sends)
|
||||
current, rev, ok, err := getSend(sends, req.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("reading it back: %v %v", err, ok)
|
||||
}
|
||||
current.Decided.Outcome = "sending"
|
||||
if _, err := sends.Update(req.ID, current, rev); err != nil {
|
||||
t.Fatalf("leaving it sending: %v", err)
|
||||
}
|
||||
sends.failKey, sends.failErr = tellsPendingKey, errors.New("the store is down")
|
||||
|
||||
emit := &memEmit{}
|
||||
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(4*time.Minute))
|
||||
if outcome != claimNotProven || d.Outcome != "not proven" {
|
||||
t.Fatalf("outcome %v, decision %+v", outcome, d)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("the index failure was not reported")
|
||||
}
|
||||
kept, _, ok2, kerr := getSend(sends, req.ID)
|
||||
if kerr != nil || !ok2 || kept.Decided == nil || kept.Decided.Outcome != "not proven" {
|
||||
t.Fatalf("not proven was not kept despite the index failing: %v %v %+v", kerr, ok2, kept.Decided)
|
||||
}
|
||||
assertEmittedFailed(t, emit, req.ID, d.Outcome)
|
||||
}
|
||||
|
||||
// Same finding, for decideSend: a failed sender-pending index write does not stop "queued" from being
|
||||
// recorded, nor the publish from being attempted — there is no separate "approved" state any more (novox/hq
|
||||
// ADR 0436 clause 8, as decided to replace the two-state design, review round 1).
|
||||
func TestDecideSendStillRecordsQueuedAndPublishesWhenTheSenderPendingIndexCannotBeWritten(t *testing.T) {
|
||||
sends := newMemSends()
|
||||
req := SendRequest{ID: newSendID(), Account: "mesh", To: []string{"ops@example.org"}, Subject: "s", Body: "body", At: judgedAt}
|
||||
rev, err := sends.Create(req.ID, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sends.failKey, sends.failErr = senderPendingKey, errors.New("the store is down")
|
||||
queue := newMemQueue()
|
||||
w := warrantFor(req, judgedAt.Add(time.Minute))
|
||||
|
||||
d, err := decideSend(sends, queue.publish, req, rev, w, judgedAt.Add(time.Minute))
|
||||
if d.Outcome != "queued" {
|
||||
t.Fatalf("decision: %+v, want queued despite the index failing", d)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("the index failure was not reported")
|
||||
}
|
||||
if n := queue.count(); n != 1 {
|
||||
t.Fatalf("published %d time(s)", n)
|
||||
}
|
||||
kept, _, ok, kerr := getSend(sends, req.ID)
|
||||
if kerr != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "queued" {
|
||||
t.Fatalf("not kept as queued despite the index failing: %v %v %+v", kerr, ok, kept.Decided)
|
||||
}
|
||||
}
|
||||
|
||||
// 1194/AC1 (as decided to replace the two-state design, review round 1): on a verified warrant the record
|
||||
// moves straight from undecided to queued by one compare-and-set, with its time, and mail.send.approved is
|
||||
// published with the send id as its message id — no "approved" state in between.
|
||||
func TestDecideSendRecordsQueuedDirectlyAndPublishes(t *testing.T) {
|
||||
sends := newMemSends()
|
||||
req := SendRequest{ID: newSendID(), Account: "mesh", To: []string{"ops@example.org"}, Subject: "s", Body: "body", At: judgedAt}
|
||||
rev, err := sends.Create(req.ID, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue := newMemQueue()
|
||||
w := warrantFor(req, judgedAt.Add(time.Minute))
|
||||
now := judgedAt.Add(time.Minute)
|
||||
|
||||
d, err := decideSend(sends, queue.publish, req, rev, w, now)
|
||||
if err != nil {
|
||||
t.Fatalf("deciding: %v", err)
|
||||
}
|
||||
if d.Outcome != "queued" || !d.At.Equal(now) {
|
||||
t.Fatalf("decision: %+v, want queued at %v", d, now)
|
||||
}
|
||||
if n := queue.count(); n != 1 {
|
||||
t.Fatalf("published %d time(s), want exactly 1", n)
|
||||
}
|
||||
got := queue.last()
|
||||
if got.id != req.ID || got.body.Send != req.ID || got.body.Account != "mesh" || got.body.Ask != req.ID {
|
||||
t.Fatalf("published %+v, want the send id as the message id and body", got)
|
||||
}
|
||||
kept, _, ok, kerr := getSend(sends, req.ID)
|
||||
if kerr != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "queued" {
|
||||
t.Fatalf("not kept as queued: %v %v %+v", kerr, ok, kept.Decided)
|
||||
}
|
||||
ids, err := senderPendingIDs(sends)
|
||||
if err != nil || len(ids) != 1 || ids[0] != req.ID {
|
||||
t.Fatalf("not indexed as sender-pending: %v %v", err, ids)
|
||||
}
|
||||
}
|
||||
|
||||
// 1196/AC2 (review round 2): a store that changes the revision between the claim and the refusal's own
|
||||
// write — another writer's decision landing in between — never emits mail.send.failed for a write that did
|
||||
// not actually land (refuse() does not retry: that is recordOutcome's own job, send.go, review round 2).
|
||||
func TestClaimQueuedSendRefuseEmitsNothingWhenTheWriteDidNotLand(t *testing.T) {
|
||||
sends, router := newMemSends(), newSendRouter()
|
||||
req := rawQueuedSend(t, sends)
|
||||
w := warrantFor(req, judgedAt.Add(time.Minute))
|
||||
w.AskDigest = "sha256:" + strings.Repeat("0", 64) // mismatched: claimQueuedSend refuses it
|
||||
router.close(w)
|
||||
racy := &raceOnFirstUpdate{SendStore: sends, race: func() {
|
||||
r, rv, ok, err := getSend(sends, req.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("racing write could not read the send: %v %v", err, ok)
|
||||
}
|
||||
if _, _, err := settleSend(sends, r, rv, SendDecision{At: judgedAt, Outcome: "not proven", Attempt: 1}); err != nil {
|
||||
t.Fatalf("racing write: %v", err)
|
||||
}
|
||||
}}
|
||||
emit := &memEmit{}
|
||||
|
||||
_, d, _, outcome, _ := claimQueuedSend(racy, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
|
||||
if outcome != claimRefused {
|
||||
t.Fatalf("outcome: %v, want claimRefused", outcome)
|
||||
}
|
||||
if len(emit.calls) != 0 {
|
||||
t.Fatalf("emitted mail.send.failed despite the refusal's own write not landing: %+v", emit.calls)
|
||||
}
|
||||
// The kept record is the racing write's own decision, not this call's: refuse() never retries.
|
||||
kept, _, ok, kerr := getSend(sends, req.ID)
|
||||
if kerr != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "not proven" {
|
||||
t.Fatalf("kept: %v %v %+v (returned %+v)", kerr, ok, kept.Decided, d)
|
||||
}
|
||||
}
|
||||
|
||||
// 1196/AC5 (operator-approved change to clause 10): given a send already sent (and one already failed,
|
||||
// refused, or not proven), when its message is delivered again, then the record is unchanged, nothing is
|
||||
// emitted or told, and the message is terminated (claimAlreadyFinished; sender.go's handler maps it to
|
||||
// Term alone, tested here the same way every other claimOutcome is in this file — by the outcome value).
|
||||
func TestClaimQueuedSendOnARedeliveryOfAnAlreadyFinishedSendTerminatesWithoutTouchingAnything(t *testing.T) {
|
||||
for name, outcome := range map[string]string{
|
||||
"sent": "sent",
|
||||
"failed": "failed: 550 no such mailbox",
|
||||
"refused": "refused: the warrant does not bind the request",
|
||||
"not proven": "not proven",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
sends, router := newMemSends(), newSendRouter()
|
||||
req := rawQueuedSend(t, sends)
|
||||
current, rev, ok, err := getSend(sends, req.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("reading it back: %v %v", err, ok)
|
||||
}
|
||||
current.Decided.Outcome = outcome
|
||||
current.Decided.Warrant = "the operator, via telegram (user id verified), chose Send"
|
||||
current.Decided.Attempt = 1
|
||||
if _, err := sends.Update(req.ID, current, rev); err != nil {
|
||||
t.Fatalf("leaving it %s: %v", outcome, err)
|
||||
}
|
||||
before := *current.Decided
|
||||
|
||||
emit := &memEmit{}
|
||||
_, d, _, got, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("claiming: %v", err)
|
||||
}
|
||||
if got != claimAlreadyFinished {
|
||||
t.Fatalf("outcome: %v, want claimAlreadyFinished", got)
|
||||
}
|
||||
if d.Outcome != before.Outcome || d.Warrant != before.Warrant || d.Attempt != before.Attempt || !d.At.Equal(before.At) {
|
||||
t.Fatalf("decision returned: %+v, want unchanged %+v", d, before)
|
||||
}
|
||||
if len(emit.calls) != 0 {
|
||||
t.Fatalf("emitted something for an already-finished send: %+v", emit.calls)
|
||||
}
|
||||
kept, _, ok, kerr := getSend(sends, req.ID)
|
||||
if kerr != nil || !ok || kept.Decided == nil {
|
||||
t.Fatalf("the send could not be read back: %v %v", kerr, ok)
|
||||
}
|
||||
if kept.Decided.Outcome != before.Outcome || kept.Decided.Warrant != before.Warrant || kept.Decided.Attempt != before.Attempt {
|
||||
t.Fatalf("the record changed: %+v, want unchanged %+v", kept.Decided, before)
|
||||
}
|
||||
ids, terr := tellPendingIDs(sends)
|
||||
if terr != nil || len(ids) != 0 {
|
||||
t.Fatalf("a tell was queued for an already-finished send: %v %v", terr, ids)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// raceOnFirstUpdate performs one competing write the first time Update is called — simulating another
|
||||
// delivery's claim landing first — so the caller's own compare-and-set loses with stdio.ErrChanged
|
||||
// (novox/hq issue 1196, AC4).
|
||||
type raceOnFirstUpdate struct {
|
||||
SendStore
|
||||
raced bool
|
||||
race func()
|
||||
}
|
||||
|
||||
func (r *raceOnFirstUpdate) Update(key string, value any, revision uint64) (uint64, error) {
|
||||
if !r.raced {
|
||||
r.raced = true
|
||||
r.race()
|
||||
}
|
||||
return r.SendStore.Update(key, value, revision)
|
||||
}
|
||||
@@ -159,33 +159,17 @@ func (s Server) check(which string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SecretPath is where the account's own secret is placed in the secrets directory, as the manifest declares the
|
||||
// family: <dir>/mail-credential-<account>.secret.
|
||||
func SecretPath(dir string, a Account) string { return filepath.Join(dir, a.Secret()+".secret") }
|
||||
|
||||
// ReadSecret is the account's password, or given false when nobody gave it yet: the mesh never makes a member of the
|
||||
// family, so a member not given is no file (ADR 0283 §6).
|
||||
func ReadSecret(dir string, a Account) (string, bool, error) {
|
||||
raw, err := os.ReadFile(SecretPath(dir, a))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
pw := strings.TrimRight(string(raw), "\r\n")
|
||||
if pw == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
return pw, true, nil
|
||||
}
|
||||
// ReadSecret and SecretPath moved into cmd/mesh-mail/internal/secret (novox/hq ADR 0436 §12, issue 1193): only
|
||||
// keep (imap.go) and the sender's own actor (sendactor.go) read a password, never the tools, which the operator
|
||||
// account's tool runner serves and which must never open a password file.
|
||||
|
||||
// SecretGiven says whether the account's own secret was given.
|
||||
//
|
||||
// It looks at the file and never reads it: whoever judges whether a password was given has no need of the password.
|
||||
// A file that is there and not empty was given; whether it signs in is the keeper's to say.
|
||||
// A file that is there and not empty was given; whether it signs in is the keeper's to say. It computes the
|
||||
// secret's path itself (not through the moved package) so the tools, which call this, never import it.
|
||||
func SecretGiven(dir string, a Account) bool {
|
||||
info, err := os.Stat(SecretPath(dir, a))
|
||||
info, err := os.Stat(filepath.Join(dir, a.Secret()+".secret"))
|
||||
return err == nil && info.Mode().IsRegular() && info.Size() > 0
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mesh-mail/cmd/mesh-mail/internal/secret"
|
||||
)
|
||||
|
||||
// The first stage's account, as the operator sets the setting `accounts` (ADR 0359 §5): its address and its server,
|
||||
@@ -22,7 +24,7 @@ func TestTheFirstStagesAccountIsReadAndItsSecretNamed(t *testing.T) {
|
||||
got[0].Incoming.Address() != "mail.novox.be:993" || got[0].Secret() != "mail-credential-mesh" || got[0].Outgoing != nil {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if p := SecretPath("/state", got[0]); p != "/state/mail-credential-mesh.secret" {
|
||||
if p := secret.Path("/state", got[0].Secret()); p != "/state/mail-credential-mesh.secret" {
|
||||
t.Fatalf("the secret is placed at %s", p)
|
||||
}
|
||||
}
|
||||
@@ -99,16 +101,16 @@ func TestASecretIsGivenByItsFileNotByReadingIt(t *testing.T) {
|
||||
if SecretGiven(dir, a) {
|
||||
t.Fatal("given before anything was written")
|
||||
}
|
||||
_ = os.WriteFile(SecretPath(dir, a), nil, 0o600)
|
||||
_ = os.WriteFile(secret.Path(dir, a.Secret()), nil, 0o600)
|
||||
if SecretGiven(dir, a) {
|
||||
t.Fatal("an empty file is a password")
|
||||
}
|
||||
_ = os.WriteFile(SecretPath(dir, a), []byte("pw\n"), 0o600)
|
||||
if pw, given, _ := ReadSecret(dir, a); !given || pw != "pw" || strings.Contains(pw, "\n") {
|
||||
_ = os.WriteFile(secret.Path(dir, a.Secret()), []byte("pw\n"), 0o600)
|
||||
if pw, given, _ := secret.Read(dir, a.Secret()); !given || pw != "pw" || strings.Contains(pw, "\n") {
|
||||
t.Fatalf("%q %v", pw, given)
|
||||
}
|
||||
// Unreadable to the judge, as a secret owned by root is to the watch: still given.
|
||||
if err := os.Chmod(SecretPath(dir, a), 0); err != nil {
|
||||
if err := os.Chmod(secret.Path(dir, a.Secret()), 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !SecretGiven(dir, a) {
|
||||
|
||||
@@ -105,6 +105,24 @@ func Judge(accounts []Account, accountsErr, readyErr error, given func(Account)
|
||||
return health.Healthy(fmt.Sprintf("%d account(s) watched: %s", len(accounts), strings.Join(fine, "; ")))
|
||||
}
|
||||
|
||||
// WithOverdue folds any overdue send (Overdue, send.go) into base: unhealthy, naming each one, whatever
|
||||
// base itself said — an overdue send is never excused by an otherwise-healthy account (novox/hq ADR 0436
|
||||
// clause 11, issue 1199).
|
||||
func WithOverdue(base health.Answer, overdue []SendRequest) health.Answer {
|
||||
if len(overdue) == 0 {
|
||||
return base
|
||||
}
|
||||
names := make([]string, 0, len(overdue))
|
||||
for _, req := range overdue {
|
||||
names = append(names, fmt.Sprintf("%s (from %s)", req.ID, req.Account))
|
||||
}
|
||||
why := fmt.Sprintf("the send(s) %s have waited 10 minutes or more for the sender", strings.Join(names, ", "))
|
||||
if base.Why != "" {
|
||||
why = base.Why + "; and " + why
|
||||
}
|
||||
return health.Unhealthy(why, base.Waits...)
|
||||
}
|
||||
|
||||
// Status is the watch's verdict as it leaves it: the answer, and when it looked.
|
||||
type Status struct {
|
||||
health.Answer
|
||||
|
||||
@@ -114,3 +114,39 @@ func TestTheWatchJudgesFromTheFilesAndTheIndex(t *testing.T) {
|
||||
t.Fatalf("the status file's shape: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 1199/AC2: mail_health (the tool, not only Overdue or WithOverdue in isolation) turns unhealthy naming an
|
||||
// overdue send, even while the watch's own verdict is healthy (novox/hq review round 1 finding 6).
|
||||
func TestMailHealthToolTurnsUnhealthyNamingAnOverdueSend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "health.json")
|
||||
if err := writeStatus(path, Status{Answer: health.Healthy("1 account(s) watched"), At: judgedAt}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sends := newMemSends()
|
||||
overdueID := "send-overdue1"
|
||||
req := SendRequest{ID: overdueID, Account: "mesh", At: judgedAt}
|
||||
req.Decided = &SendDecision{At: judgedAt.Add(-20 * time.Minute), Outcome: "queued"}
|
||||
if _, err := sends.Create(req.ID, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addSenderPending(sends, req.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &Bundle{Env: Env{Status: path}, Sends: sends, Now: func() time.Time { return judgedAt }}
|
||||
|
||||
out, err := b.healthTool(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("mail_health: %v", err)
|
||||
}
|
||||
a, ok := out.(health.Answer)
|
||||
if !ok {
|
||||
t.Fatalf("mail_health answered %T, want health.Answer", out)
|
||||
}
|
||||
if a.State != health.StateUnhealthy {
|
||||
t.Fatalf("mail_health: %+v, want unhealthy", a)
|
||||
}
|
||||
if !strings.Contains(a.Why, overdueID) {
|
||||
t.Fatalf("mail_health's why does not name the overdue send: %q", a.Why)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
|
||||
"mesh-mail/cmd/mesh-mail/internal/secret"
|
||||
)
|
||||
|
||||
// How the keeper paces itself. IdleCycle is how long one IDLE lasts before it is ended and begun again, which proves
|
||||
@@ -170,7 +172,7 @@ func (k *Keeper) Watch(ctx context.Context, a Account) {
|
||||
retry := orDefault(k.RetryFirst, RetryFirst)
|
||||
var heard, synced time.Time
|
||||
for ctx.Err() == nil {
|
||||
pw, given, err := ReadSecret(k.SecretsDir, a)
|
||||
pw, given, err := secret.Read(k.SecretsDir, a.Secret())
|
||||
switch {
|
||||
case err != nil:
|
||||
k.say(ctx, a, AccountFailing, "its password was given and cannot be read: "+err.Error(), heard, synced)
|
||||
|
||||
@@ -221,9 +221,14 @@ func TestNothingButTheTestsWritesToAMailbox(t *testing.T) {
|
||||
}
|
||||
writes := regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)\.(Store|Move|Copy|Expunge|UIDExpunge|Append|Create|Delete|Rename|Subscribe|Unsubscribe|SetMetadata|SetQuota)\(`)
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") || f == "send.go" || f == "smtp.go" {
|
||||
// Neither touches a mailbox: send.go keeps the `sends` state on the bus, never IMAP; smtp.go speaks SMTP
|
||||
// to the account's outgoing server, never to a mailbox, and only on the operator's Approve (ADR 0361 §2).
|
||||
if strings.HasSuffix(f, "_test.go") || f == "send.go" || f == "smtp.go" || f == "sender.go" || f == "mirror.go" {
|
||||
// None touches a mailbox: send.go keeps the `sends` state on the bus, never IMAP; smtp.go speaks SMTP
|
||||
// to the account's outgoing server, never to a mailbox, and only on the operator's Approve (ADR 0361
|
||||
// §2); sender.go is the sender process (novox/hq ADR 0436 §7) — its own bus account's `sends` state
|
||||
// (process.State.Create, matched here only because its receiver is named s) and router record, never
|
||||
// IMAP either; mirror.go (novox/hq ADR 0439 §2, §3, issue 1255) keeps the `accounts` and `mails`
|
||||
// state buckets on the bus, never IMAP — its Delete is a bucket key's, matched here only because its
|
||||
// KeptState field is named s.
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(f)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Package secret reads an account's own password from the secrets directory (novox/hq ADR 0283 §6). It is
|
||||
// imported only by mesh-mail's keeper (which signs in to IMAP as root) and its sender (which signs in to SMTP
|
||||
// as its own account, mesh-mail-send): never by the tools, which the operator account's tool runner serves and
|
||||
// which must never open a password file (novox/hq ADR 0436 §12, issue 1193). Moved out of package main's own
|
||||
// files so a test (secret_boundary_test.go) can prove the tools' bundle (main.go's serve path, tools.go,
|
||||
// send.go, health.go) never imports it.
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Path is where an account's own secret is placed in the secrets directory, given the family member name
|
||||
// (mail-credential-<account>): <dir>/<member>.secret.
|
||||
func Path(dir, member string) string { return filepath.Join(dir, member+".secret") }
|
||||
|
||||
// Read is the member's password, or given false when nobody gave it yet: the mesh never makes a member of the
|
||||
// family, so a member not given is no file (ADR 0283 §6). A directory that does not exist at all answers the
|
||||
// same as a file that does not exist: given false, no error (novox/hq issue 1193, AC3).
|
||||
func Read(dir, member string) (string, bool, error) {
|
||||
raw, err := os.ReadFile(Path(dir, member))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
pw := strings.TrimRight(string(raw), "\r\n")
|
||||
if pw == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
return pw, true, nil
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
"github.com/emersion/go-imap/v2/imapserver"
|
||||
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
|
||||
|
||||
"mesh-mail/cmd/mesh-mail/internal/secret"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -134,7 +136,7 @@ func testAccount() Account {
|
||||
|
||||
func giveSecret(t *testing.T, dir string, a Account, pw string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(SecretPath(dir, a), []byte(pw+"\n"), 0o600); err != nil {
|
||||
if err := os.WriteFile(secret.Path(dir, a.Secret()), []byte(pw+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,16 @@
|
||||
// mesh-mail keep acts: watches every account (IDLE on the inbox, a look at the other folders every five
|
||||
// minutes), keeps the index and the attachment store, and writes each change's event beside it
|
||||
// mesh-mail watch judges: reads every minute how each account is, and leaves its verdict for mail_health
|
||||
// mesh-mail send the sender (novox/hq ADR 0436 §7): its own bus account, consuming the work queue
|
||||
// mail-sends; it alone reads a password, and performs the one send an operator's
|
||||
// Approve authorises, once the tools have recorded it approved and queued it
|
||||
//
|
||||
// The two long-running processes are the act and the judge, as mesh-knowledge's are: keep is judged by its unit, watch
|
||||
// by the tool mail_health (ADR 0359 §6, ADR 0240 §3 and §7). Only the bundle reaches the bus, through the runtime that
|
||||
// launched it, so the keeper hands its events over in the index, written in the same step as the change they say,
|
||||
// and the bundle says them and removes each once the bus took it. stdout is the bundle's MCP channel; what this module
|
||||
// says, it says on stderr.
|
||||
// by the tool mail_health (ADR 0359 §6, ADR 0240 §3 and §7); send is judged by its unit too (it holds a bus account
|
||||
// of its own, never a seat, so no tool answers for it). Only the bundle reaches the bus through the runtime that
|
||||
// launched it; send reaches it on its own account instead (mesh-sdk go/process), so the keeper hands its events over
|
||||
// in the index, written in the same step as the change they say, and the bundle says them and removes each once the
|
||||
// bus took it. stdout is the bundle's MCP channel; what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -64,6 +68,8 @@ func main() {
|
||||
os.Exit(keep(envFromOS()))
|
||||
case "watch":
|
||||
os.Exit(watch(envFromOS()))
|
||||
case "send":
|
||||
os.Exit(send(envFromOS()))
|
||||
}
|
||||
}
|
||||
serve(envFromOS())
|
||||
@@ -181,16 +187,64 @@ func serve(env Env) {
|
||||
},
|
||||
}
|
||||
b.Logf = logf
|
||||
p := &Publisher{Index: b.Index, Emit: func(key string, body any) error { return stdio.Emit(key, body) }, Logf: logf}
|
||||
mirror := &Mirror{
|
||||
Index: b.Index,
|
||||
Accounts: func() ([]Account, error) { return ReadAccounts(env.Accounts) },
|
||||
Accts: keptMirrorState{stdio.State(AccountsState)},
|
||||
Mails: keptMirrorState{stdio.State(MailsState)},
|
||||
Logf: logf,
|
||||
}
|
||||
trigger := make(chan struct{}, 1)
|
||||
p := &Publisher{Index: b.Index, Emit: func(key string, body any) error { return stdio.Emit(key, body) }, Logf: logf,
|
||||
Said: func(int) {
|
||||
select {
|
||||
case trigger <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}}
|
||||
go p.Run(context.Background())
|
||||
go mirrorTicker(mirror, trigger)
|
||||
go hear(b)
|
||||
go sweepSends(b)
|
||||
go waitsTellTicker(b)
|
||||
if err := stdio.Serve("", b.Tools()); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// sdkTellOperator is the tools' own TellOperator (send.go), wrapping stdio.Ask to the operator channel's
|
||||
// tell verb, exactly as mesh-files' own pattern (modules/mesh-files/cmd/mesh-files/main.go tellOperator):
|
||||
// no lines, no subject naming anything the content rule would refuse.
|
||||
func sdkTellOperator(headline string) error {
|
||||
_, err := stdio.Ask("seat:operator-channel.tell", map[string]any{"headline": headline, "lines": []string{}, "subject": "mesh-mail"})
|
||||
return err
|
||||
}
|
||||
|
||||
// WaitsTellEvery is how often the tools look for an overdue send still owing their own "waits" tell
|
||||
// (novox/hq ADR 0436 clause 11, issue 1199): a second ticker, apart from sweepSends, since the two tells —
|
||||
// the sender's own failure, the tools' own overdue wait — are never the same look.
|
||||
const WaitsTellEvery = time.Minute
|
||||
|
||||
// waitsTellTicker runs the tools' own "waits" tell for every overdue send, at least every WaitsTellEvery,
|
||||
// until the process ends (novox/hq ADR 0436 clause 11, issue 1199).
|
||||
func waitsTellTicker(b *Bundle) {
|
||||
tick := time.NewTicker(WaitsTellEvery)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
overdue, err := Overdue(b.sends(), time.Now())
|
||||
if err != nil {
|
||||
logf("[mesh-mail] the overdue check cannot be read: %v", err)
|
||||
}
|
||||
for _, req := range overdue {
|
||||
if err := tellWaits(b.sends(), sdkTellOperator, req.ID, time.Now()); err != nil {
|
||||
logf("[mesh-mail] telling the operator that %s waits: %v", req.ID, err)
|
||||
}
|
||||
}
|
||||
<-tick.C
|
||||
}
|
||||
}
|
||||
|
||||
// hear subscribes to the router's word on this module's own asks, asked again until the runtime takes it, exactly
|
||||
// as mesh-knowledge does. Each warrant heard goes to Heard, the same Decide that mail_send_status uses: an
|
||||
// approved send goes out as soon as the operator answers it, never only when something later calls
|
||||
@@ -213,6 +267,32 @@ func hear(b *Bundle) {
|
||||
}
|
||||
}
|
||||
|
||||
// MirrorEvery is the Mirror's own round's slowest pace (settlement 6, issue 1255): at least this often, even
|
||||
// with nothing published in between.
|
||||
const MirrorEvery = time.Minute
|
||||
|
||||
// mirrorTicker runs the Mirror's round at least every MirrorEvery, and at once again whenever trigger fires (a
|
||||
// Publisher round that said something, settlement 6), until the process ends.
|
||||
func mirrorTicker(m *Mirror, trigger <-chan struct{}) {
|
||||
tick := time.NewTicker(MirrorEvery)
|
||||
defer tick.Stop()
|
||||
round := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := m.Round(ctx); err != nil && m.Logf != nil {
|
||||
m.Logf("[mesh-mail] the state buckets could not be mirrored: %v", err)
|
||||
}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-trigger:
|
||||
round()
|
||||
case <-tick.C:
|
||||
round()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sweepSends reads every send still undecided once a minute and asks the router's own record for its answer, so
|
||||
// an Approve answered while this module could not hear it directly is still acted on (novox/hq issue 790, review
|
||||
// round 1).
|
||||
@@ -233,6 +313,10 @@ type Publisher struct {
|
||||
Emit func(key string, body any) error
|
||||
Logf func(format string, a ...any)
|
||||
Every time.Duration
|
||||
// Said, when given, is called after a round that said one event or more (novox/hq issue 1255, settlement
|
||||
// 6): the Mirror's own round runs beside it, so the state buckets catch up without waiting for the next
|
||||
// tick.
|
||||
Said func(n int)
|
||||
}
|
||||
|
||||
// PublishBatch is how many events one round says at most.
|
||||
@@ -261,6 +345,9 @@ func (p *Publisher) Once(ctx context.Context) (int, error) {
|
||||
if err := st.Said(ctx, said); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if p.Said != nil {
|
||||
p.Said(len(said))
|
||||
}
|
||||
}
|
||||
return len(said), failed
|
||||
}
|
||||
|
||||
@@ -30,11 +30,16 @@ type manifest struct {
|
||||
OwnSecrets map[string]map[string]string `json:"own-secrets"`
|
||||
Tools []string `json:"tools"`
|
||||
Emits []string `json:"emits"`
|
||||
State []string `json:"state"`
|
||||
Streams []struct {
|
||||
Name string `json:"name"`
|
||||
Events []string `json:"events"`
|
||||
MaxAgeDays int `json:"max-age-days"`
|
||||
Grant struct {
|
||||
Name string `json:"name"`
|
||||
Events []string `json:"events"`
|
||||
MaxAgeDays int `json:"max-age-days"`
|
||||
MaxMiB int `json:"max-mib"`
|
||||
Retention string `json:"retention"`
|
||||
MaxDeliveries int `json:"max-deliveries"`
|
||||
Duplicates int `json:"duplicates"`
|
||||
Grant struct {
|
||||
Config string `json:"config"`
|
||||
Events string `json:"events"`
|
||||
} `json:"grant"`
|
||||
@@ -80,28 +85,54 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
t.Fatalf("the slug %q overflows an S3 access key", m.Slug)
|
||||
}
|
||||
fam := m.OwnSecrets[SecretFamily+"*"]
|
||||
if fam["issued-by"] != "outside" || fam["path"] != "${dir:state}/"+SecretFamily+"*.secret" || len(m.OwnSecrets) != 1 {
|
||||
if fam["issued-by"] != "outside" || fam["path"] != "${dir:state}/"+SecretFamily+"*.secret" || len(m.OwnSecrets) != 2 {
|
||||
t.Fatalf("own secrets %v", m.OwnSecrets)
|
||||
}
|
||||
// 1255/AC7, 1282: the module declares the account and mail buckets' own state, beside `sends`.
|
||||
if !reflect.DeepEqual(m.State, []string{"sends", "accounts", "mails"}) {
|
||||
t.Fatalf("state %v, want [sends accounts mails]", m.State)
|
||||
}
|
||||
var names []string
|
||||
for _, tool := range (&Bundle{}).Tools() {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
wantTools := []string{
|
||||
"mail_accounts", "mail_search", "mail_read", "mail_health", "mail_send", "mail_send_status",
|
||||
"mail-store.accounts", "mail-store.search", "mail-store.read", "mail-store.send",
|
||||
"mail-store.accounts", "mail-store.search", "mail-store.read", "mail-store.send", "mail-store.send-status",
|
||||
}
|
||||
if !reflect.DeepEqual(m.Tools, names) || !reflect.DeepEqual(names, wantTools) {
|
||||
t.Fatalf("tools %v, the code serves %v", m.Tools, names)
|
||||
}
|
||||
// Its events go to its own stream MAIL, kept a week, each account's part heard only by the consumers the trusted
|
||||
// config value grants names for it (ADR 0359 §2, §4; ADR 0363): none on the shared EVENTS stream.
|
||||
// config value grants names for it (ADR 0359 §2, §4; ADR 0363): none on the shared EVENTS stream. The module's
|
||||
// top-level emits is the sender's two outcomes (novox/hq ADR 0436 §7, §11, issue 1172): the module as a whole may
|
||||
// publish them, though only the process send actually does (its own bus.emits, checked in
|
||||
// TestItsManifestDeclaresTheSenderProcess).
|
||||
var events []string
|
||||
for _, c := range Changes {
|
||||
events = append(events, EventKey("*", c))
|
||||
}
|
||||
if len(m.Emits) != 0 || len(m.Streams) != 1 || m.Streams[0].Name != "mail" || !reflect.DeepEqual(m.Streams[0].Events, events) ||
|
||||
m.Streams[0].MaxAgeDays != 7 || m.Streams[0].Grant.Config != "grants" || m.Streams[0].Grant.Events != "mail.{key}.**" {
|
||||
var mailStream *struct {
|
||||
Name string `json:"name"`
|
||||
Events []string `json:"events"`
|
||||
MaxAgeDays int `json:"max-age-days"`
|
||||
MaxMiB int `json:"max-mib"`
|
||||
Retention string `json:"retention"`
|
||||
MaxDeliveries int `json:"max-deliveries"`
|
||||
Duplicates int `json:"duplicates"`
|
||||
Grant struct {
|
||||
Config string `json:"config"`
|
||||
Events string `json:"events"`
|
||||
} `json:"grant"`
|
||||
}
|
||||
for i := range m.Streams {
|
||||
if m.Streams[i].Name == "mail" {
|
||||
mailStream = &m.Streams[i]
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(m.Emits, []string{"mail.send.sent", "mail.send.failed"}) || len(m.Streams) != 2 || mailStream == nil ||
|
||||
!reflect.DeepEqual(mailStream.Events, events) || mailStream.MaxAgeDays != 7 || mailStream.Grant.Config != "grants" ||
|
||||
mailStream.Grant.Events != "mail.{key}.**" {
|
||||
t.Fatalf("emits %v, streams %+v; the code says %v", m.Emits, m.Streams, events)
|
||||
}
|
||||
// It calls nothing and consumes nothing: mesh-mail-notifier (issue 800 part B) is the one that invokes and
|
||||
@@ -113,7 +144,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
// mail-webclient each held once for the mesh, mail-notifier held once per machine and needing node-notifier
|
||||
// there — and claims only mail-store, serving the same four verbs as its own tools (§2, §3).
|
||||
wantSeats := []seat{
|
||||
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send"}},
|
||||
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}},
|
||||
{Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}},
|
||||
{Name: "mail-notifier", Scope: "node", Serves: []string{"status"}, Needs: []string{"node-notifier"}},
|
||||
}
|
||||
@@ -121,7 +152,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
t.Fatalf("seats %+v, want %+v", m.Seats, wantSeats)
|
||||
}
|
||||
wantClaims := []seat{
|
||||
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send"}},
|
||||
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}},
|
||||
}
|
||||
if !reflect.DeepEqual(m.Claims, wantClaims) {
|
||||
t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims)
|
||||
@@ -187,3 +218,112 @@ func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) {
|
||||
t.Fatalf("keep restarts on %v", keep["restart-on"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1172/AC2: the manifest declares the sender's account, own-secrets owner, work-queue stream and process bus,
|
||||
// and widens the state directory and accounts.json so the sender (not the operator account) can read them,
|
||||
// mirroring mesh-controller's own fixtures (internal/catalogue/process_bus_test.go:14-31,
|
||||
// own_secret_owner_test.go:16-29, stream_work_queue_test.go:13-14).
|
||||
func TestItsManifestDeclaresTheSenderProcess(t *testing.T) {
|
||||
m, byID := readManifest(t)
|
||||
|
||||
account := byID["account"]
|
||||
if account == nil || account["type"] != "user" || account["name"] != "mesh-mail-send" {
|
||||
t.Fatalf("the sender's account resource: %v", account)
|
||||
}
|
||||
|
||||
fam := m.OwnSecrets[SecretFamily+"*"]
|
||||
if fam["owner"] != "mesh-mail-send" {
|
||||
t.Fatalf("mail-credential-*'s owner: %v; want mesh-mail-send", fam["owner"])
|
||||
}
|
||||
|
||||
// The sender's own bus account is delivered as the own secret broker-<process id>, never the module's
|
||||
// own `broker` (novox/hq ADR 0436 §2): without it, the controller's mayIssue refuses to issue the
|
||||
// module at all, naming the process and the secret (review round 1, finding 1).
|
||||
broker := m.OwnSecrets["broker-send"]
|
||||
if broker["path"] != "${dir:state}/broker-send.secret" || broker["owner"] != "mesh-mail-send" {
|
||||
t.Fatalf("broker-send: %v; want path ${dir:state}/broker-send.secret, owner mesh-mail-send", broker)
|
||||
}
|
||||
|
||||
var sendsStream *struct {
|
||||
Name string `json:"name"`
|
||||
Events []string `json:"events"`
|
||||
MaxAgeDays int `json:"max-age-days"`
|
||||
MaxMiB int `json:"max-mib"`
|
||||
Retention string `json:"retention"`
|
||||
MaxDeliveries int `json:"max-deliveries"`
|
||||
Duplicates int `json:"duplicates"`
|
||||
Grant struct {
|
||||
Config string `json:"config"`
|
||||
Events string `json:"events"`
|
||||
} `json:"grant"`
|
||||
}
|
||||
for i := range m.Streams {
|
||||
if m.Streams[i].Name == "mail-sends" {
|
||||
sendsStream = &m.Streams[i]
|
||||
}
|
||||
}
|
||||
if sendsStream == nil || !reflect.DeepEqual(sendsStream.Events, []string{"mail.send.approved"}) ||
|
||||
sendsStream.MaxAgeDays != 1 || sendsStream.MaxMiB != 64 || sendsStream.Retention != "work-queue" ||
|
||||
sendsStream.MaxDeliveries != 5 || sendsStream.Duplicates != 24 {
|
||||
t.Fatalf("the stream mail-sends: %+v", sendsStream)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(m.Emits, []string{"mail.send.sent", "mail.send.failed"}) {
|
||||
t.Fatalf("top-level emits: %v", m.Emits)
|
||||
}
|
||||
|
||||
send := byID["send"]
|
||||
if send == nil || send["type"] != "process" || send["user"] != "mesh-mail-send" {
|
||||
t.Fatalf("the process send: %v", send)
|
||||
}
|
||||
run, _ := send["run"].([]any)
|
||||
if len(run) != 2 || run[0] != "./mesh-mail" || run[1] != "send" {
|
||||
t.Fatalf("send's run: %v", send["run"])
|
||||
}
|
||||
env, _ := send["env"].(map[string]any)
|
||||
keepEnv, _ := byID["keep"]["env"].(map[string]any)
|
||||
for _, k := range []string{"MESH_MAIL_ACCOUNTS", "MESH_MAIL_SECRETS"} {
|
||||
if env[k] != keepEnv[k] {
|
||||
t.Fatalf("send's %s is %v, keep's is %v", k, env[k], keepEnv[k])
|
||||
}
|
||||
}
|
||||
h, _ := send["health"].(map[string]any)
|
||||
if h["kind"] != "unit" {
|
||||
t.Fatalf("send's health: %v", send["health"])
|
||||
}
|
||||
bus, _ := send["bus"].(map[string]any)
|
||||
if bus == nil {
|
||||
t.Fatalf("send declares no bus")
|
||||
}
|
||||
if !reflect.DeepEqual(toStrings(bus["emits"]), []string{"mail.send.sent", "mail.send.failed"}) ||
|
||||
!reflect.DeepEqual(toStrings(bus["queues"]), []string{"mail-sends"}) ||
|
||||
!reflect.DeepEqual(toStrings(bus["state"]), []string{"sends"}) || bus["asks"] != true ||
|
||||
!reflect.DeepEqual(toStrings(bus["invokes"]), []string{"seat:operator-channel.tell"}) {
|
||||
t.Fatalf("send's bus: %+v", bus)
|
||||
}
|
||||
|
||||
// On novox the operator account's state directory is 0700, so mesh-mail-send cannot reach its own 0600
|
||||
// files inside it: the state directory becomes 0711 (passable, not listable) and accounts.json (server
|
||||
// names and addresses, no password) becomes 0644, so the sender can read the outgoing server. No password
|
||||
// file's mode widens (the lead's decision, issue 1172).
|
||||
if byID["state"]["mode"] != "0711" {
|
||||
t.Fatalf("the state directory's mode: %v; want 0711", byID["state"]["mode"])
|
||||
}
|
||||
if byID["accounts"]["mode"] != "0644" {
|
||||
t.Fatalf("accounts.json's mode: %v; want 0644", byID["accounts"]["mode"])
|
||||
}
|
||||
if byID["grants"]["mode"] != "0600" {
|
||||
t.Fatalf("grants.json's mode widened to %v; it holds no password, but was not asked to widen", byID["grants"]["mode"])
|
||||
}
|
||||
}
|
||||
|
||||
func toStrings(v any) []string {
|
||||
list, _ := v.([]any)
|
||||
out := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
package main
|
||||
|
||||
// The account bucket (novox/hq ADR 0439 §2, issue 1255): a view (such as the mail web client) can watch it
|
||||
// instead of asking mail-store's verbs for an account's state and folder counts. The bundle's own Mirror keeps
|
||||
// it in step with the index (design 59's prepare step, settlement 6): it reads the index fresh each round and
|
||||
// writes only what changed — a key put when its value differs, never otherwise (novox/hq ADR 0201 §12). keep
|
||||
// (imap.go) is not changed: it writes the index; the Mirror alone writes this bucket.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// AccountsState is the module's state the Mirror keeps one key per account in (novox/hq ADR 0439 §2).
|
||||
const AccountsState = "accounts"
|
||||
|
||||
// MailsState is the module's state the Mirror keeps one key per mail in (novox/hq ADR 0439 §3, issue 1282).
|
||||
const MailsState = "mails"
|
||||
|
||||
// The bounds ADR 0439 §2 gives the account bucket's value.
|
||||
const (
|
||||
// MostAccountValue is the value's own bound, 64 KiB.
|
||||
MostAccountValue = 64 << 10
|
||||
// MostFolderName is one folder's name, cut at a character's boundary when it is longer.
|
||||
MostFolderName = 120
|
||||
// DefaultKeptMails is how many of an account's newest mails the mail bucket keeps, 200 unless ADR 0439 §4's
|
||||
// 81-account rule (issue 1282) gives every account the same smaller number instead.
|
||||
DefaultKeptMails = 200
|
||||
// MostMailValue is ADR 0439 §3's own bound on one mail's value in the mail bucket, 4 KiB.
|
||||
MostMailValue = 4 << 10
|
||||
// MostMailBytes is ADR 0439 §4's own bound across every account's newest mails together, 64 MiB.
|
||||
MostMailBytes = 64 << 20
|
||||
// MostMailSubject is one mail's subject in the mail bucket, cut at a character's boundary when longer.
|
||||
MostMailSubject = 300
|
||||
// MostMailShown is how many recipients, and how many attachment names, the mail bucket's value names one
|
||||
// by one before it counts the rest (ADR 0439 §3's "first 10").
|
||||
MostMailShown = 10
|
||||
)
|
||||
|
||||
// AccountValue is state accounts' own value, one per account (ADR 0439 §2): never a time that moves on each
|
||||
// sync, so two rounds with nothing changed write the byte-identical value.
|
||||
type AccountValue struct {
|
||||
// State is the account's state as its keeper last said it (current, syncing, waits-for-sign-in or failing),
|
||||
// or "unknown" while its keeper has said nothing of it yet (as mail_accounts itself answers, tools.go).
|
||||
State string `json:"state"`
|
||||
// Kept is the number of the account's newest mails the mail bucket (issue 1282) keeps.
|
||||
Kept int `json:"kept"`
|
||||
// Folders are the account's folders, each by its name (cut to MostFolderName bytes), its count of mails and
|
||||
// its count of unread mails.
|
||||
Folders []AccountFolderValue `json:"folders"`
|
||||
// DroppedFolders is how many folders were left out, fewest-mail first, to keep the value at MostAccountValue
|
||||
// bytes or fewer; left out of the value (omitted, not zero) when nothing was dropped.
|
||||
DroppedFolders int `json:"dropped-folders,omitempty"`
|
||||
}
|
||||
|
||||
// AccountFolderValue is one folder in an AccountValue.
|
||||
type AccountFolderValue struct {
|
||||
Name string `json:"name"`
|
||||
Mails int `json:"mails"`
|
||||
Unread int `json:"unread"`
|
||||
}
|
||||
|
||||
// MailValue is state mails' own value, one per kept mail (novox/hq ADR 0439 §3): never a body. The subject is
|
||||
// cut to MostMailSubject bytes, and every address, name, folder and attachment name to MostFolderName bytes, at
|
||||
// a character's boundary; recipients and attachment names are each named one by one up to MostMailShown, the
|
||||
// rest counted, and when the value is still over MostMailValue bytes, recipients and then attachment names are
|
||||
// dropped from the end (RecipientsDropped, AttachmentsDropped growing) until it fits.
|
||||
type MailValue struct {
|
||||
Account string `json:"account"`
|
||||
Folder string `json:"folder"`
|
||||
Date string `json:"date"` // RFC3339; the mail's own Date header, never a time that moves
|
||||
Sender MailPerson `json:"sender"`
|
||||
Subject string `json:"subject"`
|
||||
Recipients []MailPerson `json:"recipients,omitempty"`
|
||||
RecipientsDropped int `json:"recipients-dropped,omitempty"`
|
||||
Attachments []string `json:"attachments,omitempty"`
|
||||
AttachmentsDropped int `json:"attachments-dropped,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
}
|
||||
|
||||
// MailPerson is a sender or a recipient in a MailValue, each field already cut to MostFolderName bytes.
|
||||
type MailPerson struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// MirrorState is what the Mirror needs of one state bucket it writes: a key's current raw value (nil when it
|
||||
// has none), a value put — the Mirror is this bucket's only writer, so never by compare-and-set — and every key
|
||||
// it holds. An interface, so a unit test gives a fake instead of a runtime (novox/hq ADR 0439 §2, §3; issue
|
||||
// 1255, 1282's "Prepare findings").
|
||||
type MirrorState interface {
|
||||
Get(key string) (json.RawMessage, bool, error)
|
||||
Put(key string, value any) error
|
||||
Delete(key string) error
|
||||
Keys() ([]string, error)
|
||||
}
|
||||
|
||||
// keptMirrorState is a MirrorState on the bus, over the sdk's own KeptState (state.go, seat.go).
|
||||
type keptMirrorState struct{ s stdio.KeptState }
|
||||
|
||||
func (k keptMirrorState) Get(key string) (json.RawMessage, bool, error) {
|
||||
e, err := k.s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}
|
||||
func (k keptMirrorState) Put(key string, value any) error {
|
||||
_, err := k.s.Put(key, value)
|
||||
return err
|
||||
}
|
||||
func (k keptMirrorState) Delete(key string) error { return k.s.Delete(key) }
|
||||
func (k keptMirrorState) Keys() ([]string, error) { return k.s.Keys() }
|
||||
|
||||
// Mirror keeps the module's state buckets in step with the index: one round reads the index fresh and writes
|
||||
// only what changed (ADR 0201 §12). It runs beside the Publisher (serve(), main.go): after each Publisher
|
||||
// round that said something, and at least every MirrorEvery either way (settlement 6).
|
||||
type Mirror struct {
|
||||
Index func(ctx context.Context) (Store, error)
|
||||
Accounts func() ([]Account, error)
|
||||
Accts MirrorState // the `accounts` state
|
||||
Mails MirrorState // the `mails` state (issue 1282); nil skips mirroring it (tests of accounts alone)
|
||||
Logf func(format string, a ...any)
|
||||
}
|
||||
|
||||
// Round mirrors every configured account's current state into the `accounts` bucket, and (when Mails is given)
|
||||
// each one's newest mails into the `mails` bucket — both at the same, shared kept count (ADR 0439 §4's
|
||||
// 81-account rule, issue 1282).
|
||||
func (m *Mirror) Round(ctx context.Context) error {
|
||||
accounts, err := m.Accounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := m.Index(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
states, err := st.States(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kept := keptPerAccount(len(accounts))
|
||||
for _, a := range accounts {
|
||||
v, err := accountValue(ctx, st, a.Name, states[a.Name], kept)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := putIfChanged(m.Accts, a.Name, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if m.Mails == nil {
|
||||
return nil
|
||||
}
|
||||
for _, a := range accounts {
|
||||
if err := m.roundMails(ctx, st, a.Name, kept); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// roundMails mirrors one account's newest kept mails into the `mails` bucket (novox/hq ADR 0439 §3, issue
|
||||
// 1282): a key put for one new or changed, a key deleted for one no longer among the newest kept — never for
|
||||
// another account's own keys, which mailKey's account prefix tells apart.
|
||||
func (m *Mirror) roundMails(ctx context.Context, st Store, account string, kept int) error {
|
||||
msgs, err := st.Newest(ctx, account, kept)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
desired := map[string]MailValue{}
|
||||
for _, msg := range msgs {
|
||||
desired[mailKey(account, msg)] = mailValue(account, msg)
|
||||
}
|
||||
for key, v := range desired {
|
||||
if err := putIfChanged(m.Mails, key, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
keys, err := m.Mails.Keys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prefix := account + "."
|
||||
for _, k := range keys {
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
continue
|
||||
}
|
||||
if _, still := desired[k]; !still {
|
||||
if err := m.Mails.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// accountValue is one account's own value: its state word, the number of mails kept for it, and its folders
|
||||
// with their counts, trimmed to MostAccountValue.
|
||||
func accountValue(ctx context.Context, st Store, account string, state AccountState, kept int) (AccountValue, error) {
|
||||
folders, err := st.Folders(ctx, account)
|
||||
if err != nil {
|
||||
return AccountValue{}, err
|
||||
}
|
||||
fv := make([]AccountFolderValue, 0, len(folders))
|
||||
for _, f := range folders {
|
||||
unread, err := unreadCount(ctx, st, account, f.Name)
|
||||
if err != nil {
|
||||
return AccountValue{}, err
|
||||
}
|
||||
fv = append(fv, AccountFolderValue{Name: cutUTF8(f.Name, MostFolderName), Mails: f.Messages, Unread: unread})
|
||||
}
|
||||
word := state.State
|
||||
if word == "" {
|
||||
word = "unknown"
|
||||
}
|
||||
return trimAccountValue(AccountValue{State: word, Kept: kept, Folders: fv}), nil
|
||||
}
|
||||
|
||||
// unreadCount is a folder's count of messages without the \Seen flag.
|
||||
func unreadCount(ctx context.Context, st Store, account, folder string) (int, error) {
|
||||
flags, err := st.Flags(ctx, account, folder)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
for _, fl := range flags {
|
||||
if !hasSeenFlag(fl) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// trimAccountValue is issue 1255/AC5: while the value marshals to more than MostAccountValue bytes, the folder
|
||||
// with the fewest mails is dropped, and DroppedFolders counts it, until it fits (or no folder is left).
|
||||
func trimAccountValue(v AccountValue) AccountValue {
|
||||
for accountValueSize(v) > MostAccountValue && len(v.Folders) > 0 {
|
||||
fewest := 0
|
||||
for i := 1; i < len(v.Folders); i++ {
|
||||
if v.Folders[i].Mails < v.Folders[fewest].Mails {
|
||||
fewest = i
|
||||
}
|
||||
}
|
||||
v.Folders = append(append([]AccountFolderValue{}, v.Folders[:fewest]...), v.Folders[fewest+1:]...)
|
||||
v.DroppedFolders++
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func accountValueSize(v AccountValue) int {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(raw)
|
||||
}
|
||||
|
||||
// keptPerAccount is ADR 0439 §4's 81-account rule (issue 1282): DefaultKeptMails, unless that many of every
|
||||
// account's mails could together pass MostMailBytes, in which case every account keeps the same smaller number
|
||||
// instead, so that all of them fit.
|
||||
func keptPerAccount(numAccounts int) int {
|
||||
if numAccounts <= 0 {
|
||||
return DefaultKeptMails
|
||||
}
|
||||
if numAccounts*DefaultKeptMails*MostMailValue <= MostMailBytes {
|
||||
return DefaultKeptMails
|
||||
}
|
||||
n := MostMailBytes / (numAccounts * MostMailValue)
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if n > DefaultKeptMails {
|
||||
n = DefaultKeptMails
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// putIfChanged writes value at key only when it differs from what the bucket currently holds there (ADR 0201
|
||||
// §12): the Mirror's own values never carry a time that moves on each sync, so the same JSON bytes (Go's own
|
||||
// json.Marshal is stable for a fixed type) mean nothing changed.
|
||||
func putIfChanged(bucket MirrorState, key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur, ok, err := bucket.Get(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok && bytes.Equal(cur, raw) {
|
||||
return nil
|
||||
}
|
||||
return bucket.Put(key, value)
|
||||
}
|
||||
|
||||
// mailDigest is the digest named by a mail's own message-id, or by its folder and uid when it has none (novox/hq
|
||||
// ADR 0439 §3, issue 1282, AC5): two mails without a message-id in different folders or uids digest apart; one
|
||||
// message-id kept in two folders of the same account digests the same, so it is mirrored once.
|
||||
func mailDigest(m Message) string {
|
||||
basis := m.MessageID
|
||||
if basis == "" {
|
||||
basis = m.Folder + "\x00" + itoa(int64(m.UID))
|
||||
}
|
||||
sum := sha256.Sum256([]byte(basis))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// mailKey is the mail bucket's own key: the account, so one account's keys are told apart from another's
|
||||
// (roundMails's own deletion), then a dot (account names hold none, config.go's accountName), then the digest.
|
||||
func mailKey(account string, m Message) string {
|
||||
return account + "." + mailDigest(m)
|
||||
}
|
||||
|
||||
// mailValue is one mail's own value (ADR 0439 §3): account, folder, date, sender, subject, recipients and
|
||||
// attachment names (each capped to MostMailShown, the rest counted), size and flags, never a body, trimmed to
|
||||
// MostMailValue.
|
||||
func mailValue(account string, m Message) MailValue {
|
||||
sender := MailPerson{}
|
||||
if len(m.From) > 0 {
|
||||
sender = mailPerson(m.From[0])
|
||||
}
|
||||
recipients, recipientsDropped := mailPeople(m.To)
|
||||
attachments, attachmentsDropped := mailAttachmentNames(m.Attachments)
|
||||
v := MailValue{
|
||||
Account: account,
|
||||
Folder: cutUTF8(m.Folder, MostFolderName),
|
||||
Date: m.Date.UTC().Format(time.RFC3339),
|
||||
Sender: sender,
|
||||
Subject: cutUTF8(m.Subject, MostMailSubject),
|
||||
Recipients: recipients,
|
||||
RecipientsDropped: recipientsDropped,
|
||||
Attachments: attachments,
|
||||
AttachmentsDropped: attachmentsDropped,
|
||||
Size: m.Size,
|
||||
Flags: append([]string(nil), m.Flags...),
|
||||
}
|
||||
return trimMailValue(v)
|
||||
}
|
||||
|
||||
// mailPerson is one Person as a MailValue names it: name and address each cut to MostFolderName bytes.
|
||||
func mailPerson(p Person) MailPerson {
|
||||
return MailPerson{Name: cutUTF8(p.Name, MostFolderName), Address: cutUTF8(p.Address, MostFolderName)}
|
||||
}
|
||||
|
||||
// mailPeople is a list of recipients, capped to MostMailShown with the rest counted (ADR 0439 §3's "first 10
|
||||
// recipients").
|
||||
func mailPeople(ps []Person) ([]MailPerson, int) {
|
||||
dropped := 0
|
||||
if len(ps) > MostMailShown {
|
||||
dropped = len(ps) - MostMailShown
|
||||
ps = ps[:MostMailShown]
|
||||
}
|
||||
out := make([]MailPerson, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
out = append(out, mailPerson(p))
|
||||
}
|
||||
return out, dropped
|
||||
}
|
||||
|
||||
// mailAttachmentNames is a list of attachment names, capped to MostMailShown with the rest counted (ADR 0439
|
||||
// §3's "first 10 attachment names"), each cut to MostFolderName bytes.
|
||||
func mailAttachmentNames(refs []AttachmentRef) ([]string, int) {
|
||||
dropped := 0
|
||||
if len(refs) > MostMailShown {
|
||||
dropped = len(refs) - MostMailShown
|
||||
refs = refs[:MostMailShown]
|
||||
}
|
||||
out := make([]string, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
out = append(out, cutUTF8(r.Name, MostFolderName))
|
||||
}
|
||||
return out, dropped
|
||||
}
|
||||
|
||||
// trimMailValue is issue 1282/AC4: while the value marshals to more than MostMailValue bytes, a recipient is
|
||||
// dropped from the end (RecipientsDropped counting it) until none is left, then an attachment name the same
|
||||
// way (AttachmentsDropped counting it), until it fits or both are empty.
|
||||
func trimMailValue(v MailValue) MailValue {
|
||||
for mailValueSize(v) > MostMailValue {
|
||||
switch {
|
||||
case len(v.Recipients) > 0:
|
||||
v.Recipients = v.Recipients[:len(v.Recipients)-1]
|
||||
v.RecipientsDropped++
|
||||
case len(v.Attachments) > 0:
|
||||
v.Attachments = v.Attachments[:len(v.Attachments)-1]
|
||||
v.AttachmentsDropped++
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mailValueSize(v MailValue) int {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(raw)
|
||||
}
|
||||
|
||||
// cutUTF8 cuts s to at most n bytes, never splitting a multi-byte character (novox/hq ADR 0439 §2, §3).
|
||||
func cutUTF8(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
b := s[:n]
|
||||
for len(b) > 0 {
|
||||
r, size := utf8.DecodeLastRuneInString(b)
|
||||
if r == utf8.RuneError && size <= 1 {
|
||||
b = b[:len(b)-1]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
package main
|
||||
|
||||
// The account bucket (novox/hq ADR 0439 §2, issue 1255) and the mail bucket (ADR 0439 §3, issue 1282): a fake
|
||||
// MirrorState, so these tests need no runtime, and memStore for the index, as the rest of this package's unit
|
||||
// tests already use it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// memMirrorState is a fake MirrorState (mirror.go), kept in memory: a map, no revision, since the Mirror is a
|
||||
// bucket's only writer and never writes it by compare-and-set.
|
||||
type memMirrorState struct {
|
||||
vals map[string]json.RawMessage
|
||||
calls map[string]int // Put calls per key, so a test can tell a skipped write from a repeated one
|
||||
}
|
||||
|
||||
func newMemMirrorState() *memMirrorState {
|
||||
return &memMirrorState{vals: map[string]json.RawMessage{}, calls: map[string]int{}}
|
||||
}
|
||||
|
||||
func (m *memMirrorState) Get(key string) (json.RawMessage, bool, error) {
|
||||
v, ok := m.vals[key]
|
||||
return v, ok, nil
|
||||
}
|
||||
func (m *memMirrorState) Put(key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.vals[key] = raw
|
||||
m.calls[key]++
|
||||
return nil
|
||||
}
|
||||
func (m *memMirrorState) Delete(key string) error {
|
||||
delete(m.vals, key)
|
||||
return nil
|
||||
}
|
||||
func (m *memMirrorState) Keys() ([]string, error) {
|
||||
out := make([]string, 0, len(m.vals))
|
||||
for k := range m.vals {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// accountsAndStore is one account "mesh", granted no caller (the Mirror reads no grant), with the index given.
|
||||
func accountsAndStore(t *testing.T) ([]Account, *memStore) {
|
||||
t.Helper()
|
||||
return []Account{{Name: "mesh", Kind: KindIMAP, Address: "jschoubben@novox.be"}}, newMemStore()
|
||||
}
|
||||
|
||||
func mirrorOf(accounts []Account, st *memStore, bucket MirrorState) *Mirror {
|
||||
return &Mirror{
|
||||
Index: func(context.Context) (Store, error) { return st, nil },
|
||||
Accounts: func() ([]Account, error) { return accounts, nil },
|
||||
Accts: bucket,
|
||||
}
|
||||
}
|
||||
|
||||
// mirrorWithMails is mirrorOf, also mirroring the `mails` bucket (issue 1282).
|
||||
func mirrorWithMails(accounts []Account, st *memStore, accts, mails MirrorState) *Mirror {
|
||||
m := mirrorOf(accounts, st, accts)
|
||||
m.Mails = mails
|
||||
return m
|
||||
}
|
||||
|
||||
// 1255/AC1: the account's value holds its state word, the number of newest mails kept (200, no other account
|
||||
// configured, so none of ADR 0439 §4's 81-account rule applies), and its folders with their counts.
|
||||
func TestMirrorRoundWritesTheAccountsStateWord200AndItsFolders(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts, st := accountsAndStore(t)
|
||||
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent, Why: "watching"})
|
||||
addMail(t, st, "mesh", "INBOX", 1, []string{}) // unread
|
||||
addMail(t, st, "mesh", "INBOX", 2, []string{`\Seen`}) // read
|
||||
addMail(t, st, "mesh", "Sent", 3, []string{`\Seen`}) // read
|
||||
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 10})
|
||||
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "Sent", Messages: 5})
|
||||
|
||||
bucket := newMemMirrorState()
|
||||
m := mirrorOf(accounts, st, bucket)
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, ok, err := bucket.Get("mesh")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("no value kept for mesh: %v %v", ok, err)
|
||||
}
|
||||
var v AccountValue
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.State != AccountCurrent || v.Kept != DefaultKeptMails {
|
||||
t.Fatalf("state %q, kept %d; want %q, %d", v.State, v.Kept, AccountCurrent, DefaultKeptMails)
|
||||
}
|
||||
byName := map[string]AccountFolderValue{}
|
||||
for _, f := range v.Folders {
|
||||
byName[f.Name] = f
|
||||
}
|
||||
if byName["INBOX"].Mails != 10 || byName["INBOX"].Unread != 1 {
|
||||
t.Fatalf("INBOX: %+v", byName["INBOX"])
|
||||
}
|
||||
if byName["Sent"].Mails != 5 || byName["Sent"].Unread != 0 {
|
||||
t.Fatalf("Sent: %+v", byName["Sent"])
|
||||
}
|
||||
}
|
||||
|
||||
// addMail indexes one bare message with the given flags, for folder-count tests that only need its UID and
|
||||
// flags (mirroring the shape imap.go's Keeper writes, store.go's Message).
|
||||
func addMail(t *testing.T, st *memStore, account, folder string, uid uint32, flags []string) {
|
||||
t.Helper()
|
||||
m := Message{Account: account, Folder: folder, UID: uid, Flags: flags}
|
||||
if err := st.Add(context.Background(), m, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1255/AC2: nothing is written when the index is unchanged since the last round.
|
||||
func TestMirrorRoundWritesNothingWhenUnchanged(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts, st := accountsAndStore(t)
|
||||
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent})
|
||||
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 1})
|
||||
|
||||
bucket := newMemMirrorState()
|
||||
m := mirrorOf(accounts, st, bucket)
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bucket.calls["mesh"] != 1 {
|
||||
t.Fatalf("the first round wrote %d time(s); want 1", bucket.calls["mesh"])
|
||||
}
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bucket.calls["mesh"] != 1 {
|
||||
t.Fatalf("a round with nothing changed wrote again: %d time(s)", bucket.calls["mesh"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1255/AC3: one mail of INBOX marked read changes only that account's key, to 1 unread.
|
||||
func TestMirrorRoundWritesOnlyTheChangedAccountsKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts, st := accountsAndStore(t)
|
||||
accounts = append(accounts, Account{Name: "other", Kind: KindIMAP, Address: "other@novox.be"})
|
||||
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent})
|
||||
_ = st.SetState(ctx, AccountState{Account: "other", State: AccountCurrent})
|
||||
addMail(t, st, "mesh", "INBOX", 1, []string{})
|
||||
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 1})
|
||||
_ = st.SetFolder(ctx, Folder{Account: "other", Name: "INBOX", Messages: 0})
|
||||
|
||||
bucket := newMemMirrorState()
|
||||
m := mirrorOf(accounts, st, bucket)
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket.calls = map[string]int{} // reset the counters; only the next round's writes matter here
|
||||
|
||||
// mesh's one message is now marked read.
|
||||
if err := st.SetFlags(ctx, "mesh", "INBOX", 1, []string{`\Seen`}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bucket.calls["mesh"] != 1 {
|
||||
t.Fatalf("mesh's key was not rewritten: %d call(s)", bucket.calls["mesh"])
|
||||
}
|
||||
if bucket.calls["other"] != 0 {
|
||||
t.Fatalf("other's key was written though nothing of it changed: %d call(s)", bucket.calls["other"])
|
||||
}
|
||||
raw, _, _ := bucket.Get("mesh")
|
||||
var v AccountValue
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
for _, f := range v.Folders {
|
||||
if f.Name == "INBOX" && f.Unread != 0 {
|
||||
t.Fatalf("INBOX unread after marking read: %d", f.Unread)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1255/AC4: a folder name of 200 bytes of multibyte characters is cut to at most 120 bytes at a character's
|
||||
// boundary (never split mid-character).
|
||||
func TestMirrorRoundCutsALongMultibyteFolderNameAtACharacterBoundary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts, st := accountsAndStore(t)
|
||||
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent})
|
||||
// 119 one-byte letters, then 2-byte "é"s: the 120th byte (MostFolderName) lands mid-character, inside the
|
||||
// first é's 2-byte encoding, so a naive byte cut would split it.
|
||||
long := strings.Repeat("a", 119) + strings.Repeat("é", 40)
|
||||
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: long, Messages: 1})
|
||||
|
||||
bucket := newMemMirrorState()
|
||||
m := mirrorOf(accounts, st, bucket)
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _, _ := bucket.Get("mesh")
|
||||
var v AccountValue
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
if len(v.Folders) != 1 {
|
||||
t.Fatalf("folders: %+v", v.Folders)
|
||||
}
|
||||
got := v.Folders[0].Name
|
||||
if len(got) > MostFolderName {
|
||||
t.Fatalf("the folder name is %d bytes, more than %d", len(got), MostFolderName)
|
||||
}
|
||||
if !strings.HasPrefix(long, got) {
|
||||
t.Fatalf("the cut name is not a prefix of the original: %q", got)
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("the cut name is not valid UTF-8: a multibyte character was split: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1255/AC5: an account with enough folders that the value would pass 64 KiB has the folders with the fewest
|
||||
// mails dropped, their number counted, and the value fits.
|
||||
func TestMirrorRoundDropsTheFewestMailFoldersToFitUnder64KiB(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts, st := accountsAndStore(t)
|
||||
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent})
|
||||
// Enough folders, with long enough names, that the whole value passes MostAccountValue; each folder's own
|
||||
// mail count is distinct, so "fewest first" is unambiguous.
|
||||
const folders = 1200
|
||||
for i := 0; i < folders; i++ {
|
||||
name := "folder-" + itoa(int64(i)) + "-abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: name, Messages: i + 1})
|
||||
}
|
||||
|
||||
bucket := newMemMirrorState()
|
||||
m := mirrorOf(accounts, st, bucket)
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _, _ := bucket.Get("mesh")
|
||||
if len(raw) > MostAccountValue {
|
||||
t.Fatalf("the value is %d bytes, more than %d", len(raw), MostAccountValue)
|
||||
}
|
||||
var v AccountValue
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
if v.DroppedFolders == 0 {
|
||||
t.Fatalf("no folder was dropped, though %d folders would overflow %d bytes", folders, MostAccountValue)
|
||||
}
|
||||
if v.DroppedFolders+len(v.Folders) != folders {
|
||||
t.Fatalf("dropped %d + kept %d != %d folders in all", v.DroppedFolders, len(v.Folders), folders)
|
||||
}
|
||||
// The kept folders are never among the ones with the fewest mails: folder i has i+1 mails, so every kept
|
||||
// folder's count is at least as large as every dropped folder's would have been.
|
||||
fewestKept := -1
|
||||
for _, f := range v.Folders {
|
||||
if fewestKept == -1 || f.Mails < fewestKept {
|
||||
fewestKept = f.Mails
|
||||
}
|
||||
}
|
||||
if fewestKept < v.DroppedFolders {
|
||||
t.Fatalf("the fewest-mail folders were not the ones dropped: kept as few as %d mails, dropped %d folders", fewestKept, v.DroppedFolders)
|
||||
}
|
||||
}
|
||||
|
||||
// 1255/AC6: two rounds an hour apart with nothing indexed between write the byte-identical value (no time that
|
||||
// moves on each sync).
|
||||
func TestMirrorRoundTwoRoundsWithNothingChangedWriteByteIdenticalValues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts, st := accountsAndStore(t)
|
||||
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent})
|
||||
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 3})
|
||||
|
||||
bucket := newMemMirrorState()
|
||||
m := mirrorOf(accounts, st, bucket)
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, _, _ := bucket.Get("mesh")
|
||||
firstCopy := append([]byte(nil), first...)
|
||||
|
||||
// Force a second write by deleting the key directly and running again: the value computed must still be
|
||||
// byte-identical to the first, since nothing in the index changed.
|
||||
_ = bucket.Delete("mesh")
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _, _ := bucket.Get("mesh")
|
||||
if string(firstCopy) != string(second) {
|
||||
t.Fatalf("the two rounds wrote different bytes:\n%s\n%s", firstCopy, second)
|
||||
}
|
||||
}
|
||||
|
||||
// 1255/AC7: the manifest declares the state accounts (manifest_test.go asserts the exact list; this just
|
||||
// anchors the constant the code and the manifest both use).
|
||||
func TestAccountsStateNameMatchesTheManifestsWord(t *testing.T) {
|
||||
if AccountsState != "accounts" {
|
||||
t.Fatalf("AccountsState = %q", AccountsState)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the mail bucket (novox/hq ADR 0439 §3, issue 1282) -----------------------------------------------------
|
||||
|
||||
// 1282/AC1: a new mail indexed gets one key in `mails`, named by its account and a digest of its message-id,
|
||||
// whose value carries its account, folder, date, sender, subject, recipients, attachment names, size and
|
||||
// flags, and no body.
|
||||
func TestMirrorRoundWritesANewMailsKeyWithItsFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts, st := accountsAndStore(t)
|
||||
msg := Message{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "m1@example.org", Date: judgedAt,
|
||||
From: []Person{{Name: "Bob", Address: "bob@example.org"}}, To: []Person{{Address: "ops@example.org"}},
|
||||
Subject: "Hi", Size: 1234, Flags: []string{`\Seen`},
|
||||
Attachments: []AttachmentRef{{Key: "k1", Name: "invoice.pdf", Size: 10, Type: "application/pdf"}},
|
||||
Body: "should never appear in the mirrored value",
|
||||
}
|
||||
if err := st.Add(ctx, msg, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 1})
|
||||
|
||||
acctsBucket, mailsBucket := newMemMirrorState(), newMemMirrorState()
|
||||
m := mirrorWithMails(accounts, st, acctsBucket, mailsBucket)
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, ok, err := mailsBucket.Get(mailKey("mesh", msg))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("no value kept for the new mail: %v %v", ok, err)
|
||||
}
|
||||
var v MailValue
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Account != "mesh" || v.Folder != "INBOX" || v.Subject != "Hi" || v.Size != 1234 {
|
||||
t.Fatalf("%+v", v)
|
||||
}
|
||||
if v.Sender.Name != "Bob" || v.Sender.Address != "bob@example.org" {
|
||||
t.Fatalf("sender: %+v", v.Sender)
|
||||
}
|
||||
if len(v.Recipients) != 1 || v.Recipients[0].Address != "ops@example.org" {
|
||||
t.Fatalf("recipients: %+v", v.Recipients)
|
||||
}
|
||||
if len(v.Attachments) != 1 || v.Attachments[0] != "invoice.pdf" {
|
||||
t.Fatalf("attachments: %+v", v.Attachments)
|
||||
}
|
||||
if len(v.Flags) != 1 || v.Flags[0] != `\Seen` {
|
||||
t.Fatalf("flags: %+v", v.Flags)
|
||||
}
|
||||
if strings.Contains(string(raw), "should never appear") {
|
||||
t.Fatalf("the value carries the body: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 1282/AC2: a kept mail whose flags change has its key rewritten; a round with nothing changed writes nothing.
|
||||
func TestMirrorRoundRewritesOnlyAChangedMailsKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts, st := accountsAndStore(t)
|
||||
a := Message{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "a@example.org", Date: judgedAt, Subject: "a"}
|
||||
b := Message{Account: "mesh", Folder: "INBOX", UID: 2, MessageID: "b@example.org", Date: judgedAt, Subject: "b"}
|
||||
if err := st.Add(ctx, a, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Add(ctx, b, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 2})
|
||||
|
||||
acctsBucket, mailsBucket := newMemMirrorState(), newMemMirrorState()
|
||||
m := mirrorWithMails(accounts, st, acctsBucket, mailsBucket)
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mailsBucket.calls = map[string]int{}
|
||||
|
||||
// Nothing changed: a second round writes neither key again.
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mailsBucket.calls[mailKey("mesh", a)] != 0 || mailsBucket.calls[mailKey("mesh", b)] != 0 {
|
||||
t.Fatalf("a round with nothing changed wrote a mail key: %v", mailsBucket.calls)
|
||||
}
|
||||
|
||||
// a's flags change: only a's key is rewritten.
|
||||
if err := st.SetFlags(ctx, "mesh", "INBOX", 1, []string{`\Seen`}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mailsBucket.calls[mailKey("mesh", a)] != 1 {
|
||||
t.Fatalf("a's key was not rewritten: %v", mailsBucket.calls)
|
||||
}
|
||||
if mailsBucket.calls[mailKey("mesh", b)] != 0 {
|
||||
t.Fatalf("b's key was written though nothing of it changed: %v", mailsBucket.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1282/AC3 (removed): a mail removed from the index has its key deleted.
|
||||
func TestMirrorRoundDeletesAMailsKeyForARemovedMail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, st := accountsAndStore(t)
|
||||
msg := Message{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "m@example.org", Date: judgedAt, Subject: "s"}
|
||||
if err := st.Add(context.Background(), msg, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := newMemMirrorState()
|
||||
mir := &Mirror{Mails: bucket}
|
||||
if err := mir.roundMails(ctx, st, "mesh", 200); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := bucket.Get(mailKey("mesh", msg)); !ok {
|
||||
t.Fatalf("the mail's key was not written")
|
||||
}
|
||||
|
||||
if err := st.Remove(ctx, "mesh", "INBOX", 1, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mir.roundMails(ctx, st, "mesh", 200); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := bucket.Get(mailKey("mesh", msg)); ok {
|
||||
t.Fatalf("the removed mail's key was not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// 1282/AC3 (pushed out): a mail pushed out of the newest kept by a newer one has its key deleted.
|
||||
func TestMirrorRoundDeletesAMailsKeyPushedOutOfTheNewestKept(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, st := accountsAndStore(t)
|
||||
old := Message{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "old@example.org", Date: judgedAt.Add(-time.Hour), Subject: "old"}
|
||||
if err := st.Add(ctx, old, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := newMemMirrorState()
|
||||
mir := &Mirror{Mails: bucket}
|
||||
if err := mir.roundMails(ctx, st, "mesh", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := bucket.Get(mailKey("mesh", old)); !ok {
|
||||
t.Fatalf("the old mail's key was not written")
|
||||
}
|
||||
|
||||
newer := Message{Account: "mesh", Folder: "INBOX", UID: 2, MessageID: "new@example.org", Date: judgedAt, Subject: "new"}
|
||||
if err := st.Add(ctx, newer, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mir.roundMails(ctx, st, "mesh", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := bucket.Get(mailKey("mesh", old)); ok {
|
||||
t.Fatalf("the old mail's key was not deleted once pushed out of the newest kept")
|
||||
}
|
||||
if _, ok, _ := bucket.Get(mailKey("mesh", newer)); !ok {
|
||||
t.Fatalf("the newer mail's key was not written")
|
||||
}
|
||||
}
|
||||
|
||||
// 1282/AC4: a mail with 500 recipients of 254-byte addresses, 50 attachments of 255-byte names and a
|
||||
// 5,000-character multibyte subject is mirrored at most MostMailValue bytes, its subject at most
|
||||
// MostMailSubject, each address and attachment name at most MostFolderName, and the dropped counts are in it.
|
||||
func TestMirrorRoundTrimsAnOversizedMailValueToAtMost4KiB(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts, st := accountsAndStore(t)
|
||||
var to []Person
|
||||
for i := 0; i < 500; i++ {
|
||||
to = append(to, Person{Address: strings.Repeat("r", 240) + "@example.org"}) // 254 bytes
|
||||
}
|
||||
var atts []AttachmentRef
|
||||
for i := 0; i < 50; i++ {
|
||||
atts = append(atts, AttachmentRef{Key: "k" + itoa(int64(i)), Name: strings.Repeat("n", 255), Size: 10})
|
||||
}
|
||||
subject := strings.Repeat("é", 5000)
|
||||
msg := Message{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "big@example.org", Date: judgedAt,
|
||||
From: []Person{{Address: "sender@example.org"}}, To: to, Subject: subject, Size: 99, Attachments: atts}
|
||||
if err := st.Add(ctx, msg, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 1})
|
||||
|
||||
acctsBucket, mailsBucket := newMemMirrorState(), newMemMirrorState()
|
||||
m := mirrorWithMails(accounts, st, acctsBucket, mailsBucket)
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, ok, err := mailsBucket.Get(mailKey("mesh", msg))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("no value kept: %v %v", ok, err)
|
||||
}
|
||||
if len(raw) > MostMailValue {
|
||||
t.Fatalf("the value is %d bytes, more than %d", len(raw), MostMailValue)
|
||||
}
|
||||
var v MailValue
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(v.Subject) > MostMailSubject {
|
||||
t.Fatalf("the subject is %d bytes, more than %d", len(v.Subject), MostMailSubject)
|
||||
}
|
||||
if len(v.Recipients) > MostMailShown {
|
||||
t.Fatalf("%d recipients shown, more than %d", len(v.Recipients), MostMailShown)
|
||||
}
|
||||
for _, r := range v.Recipients {
|
||||
if len(r.Address) > MostFolderName {
|
||||
t.Fatalf("a recipient's address is %d bytes, more than %d", len(r.Address), MostFolderName)
|
||||
}
|
||||
}
|
||||
for _, a := range v.Attachments {
|
||||
if len(a) > MostFolderName {
|
||||
t.Fatalf("an attachment name is %d bytes, more than %d", len(a), MostFolderName)
|
||||
}
|
||||
}
|
||||
if v.RecipientsDropped == 0 {
|
||||
t.Fatalf("recipients-dropped is 0, though 500 recipients were given")
|
||||
}
|
||||
if v.AttachmentsDropped == 0 {
|
||||
t.Fatalf("attachments-dropped is 0, though 50 attachments were given")
|
||||
}
|
||||
}
|
||||
|
||||
// 1282/AC5: two mails without a message-id, in different folders or uids, digest apart (two keys); one
|
||||
// message-id kept in two folders of one account digests the same (one key).
|
||||
func TestMailDigestDistinguishesByFolderAndUIDAndMergesByMessageID(t *testing.T) {
|
||||
a := Message{Folder: "INBOX", UID: 1}
|
||||
b := Message{Folder: "INBOX", UID: 2}
|
||||
if mailDigest(a) == mailDigest(b) {
|
||||
t.Fatalf("two mails without a message-id, different uids, digest the same")
|
||||
}
|
||||
c := Message{Folder: "Archive", UID: 1}
|
||||
if mailDigest(a) == mailDigest(c) {
|
||||
t.Fatalf("two mails without a message-id, different folders, digest the same")
|
||||
}
|
||||
withID1 := Message{Folder: "INBOX", UID: 9, MessageID: "x@example.org"}
|
||||
withID2 := Message{Folder: "Sent", UID: 10, MessageID: "x@example.org"}
|
||||
if mailDigest(withID1) != mailDigest(withID2) {
|
||||
t.Fatalf("one message-id kept in two folders of the account digests apart: %q vs %q",
|
||||
mailDigest(withID1), mailDigest(withID2))
|
||||
}
|
||||
}
|
||||
|
||||
// 1282/AC6: with 82 accounts, every account keeps the same smaller number n of newest mails, with
|
||||
// 82 x n x MostMailValue at most MostMailBytes, and n is written in each account's own `accounts` value
|
||||
// (issue 1255's Kept field).
|
||||
func TestMirrorRoundUses81AccountRuleForTheKeptNumber(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var accounts []Account
|
||||
for i := 0; i < 82; i++ {
|
||||
accounts = append(accounts, Account{Name: "a" + itoa(int64(i)), Kind: KindIMAP, Address: "a@example.org"})
|
||||
}
|
||||
st := newMemStore()
|
||||
for _, a := range accounts {
|
||||
if err := st.SetState(ctx, AccountState{Account: a.Name, State: AccountCurrent}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
n := keptPerAccount(82)
|
||||
if n >= DefaultKeptMails {
|
||||
t.Fatalf("82 accounts did not reduce the kept number below %d: got %d", DefaultKeptMails, n)
|
||||
}
|
||||
if 82*n*MostMailValue > MostMailBytes {
|
||||
t.Fatalf("82 x %d x %d bytes is more than %d", n, MostMailValue, MostMailBytes)
|
||||
}
|
||||
|
||||
acctsBucket, mailsBucket := newMemMirrorState(), newMemMirrorState()
|
||||
m := mirrorWithMails(accounts, st, acctsBucket, mailsBucket)
|
||||
if err := m.Round(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, a := range accounts {
|
||||
raw, ok, err := acctsBucket.Get(a.Name)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("%s: no value: %v %v", a.Name, ok, err)
|
||||
}
|
||||
var v AccountValue
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Kept != n {
|
||||
t.Fatalf("%s: kept %d, want %d", a.Name, v.Kept, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package main
|
||||
|
||||
// recordOutcome writes the final decision for one claimed send and says what the caller does with the
|
||||
// queue message next (novox/hq ADR 0436 clauses 10, 11, issue 1198).
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// emitted is one call recordOutcome's Emit made.
|
||||
type emitted struct {
|
||||
eventType, id string
|
||||
body OutcomeEvent
|
||||
}
|
||||
|
||||
// memEmit collects every emit call, for a test to check the event and its body.
|
||||
type memEmit struct{ calls []emitted }
|
||||
|
||||
func (m *memEmit) emit(eventType, id string, body any) error {
|
||||
b, _ := body.(OutcomeEvent)
|
||||
m.calls = append(m.calls, emitted{eventType: eventType, id: id, body: b})
|
||||
return nil
|
||||
}
|
||||
|
||||
// outcomeTestClaim keeps one send claimed "sending" at the given attempt, returning its request, the
|
||||
// claimed decision and the revision recordOutcome needs.
|
||||
func outcomeTestClaim(t *testing.T, attempt int) (SendRequest, SendDecision, uint64, *memSends) {
|
||||
t.Helper()
|
||||
sends := newMemSends()
|
||||
req := SendRequest{ID: newSendID(), Account: "mesh", To: []string{"ops@example.org"}, Subject: "s", Body: "body", At: judgedAt}
|
||||
claim := SendDecision{At: judgedAt.Add(time.Minute), Outcome: "sending", Warrant: "the operator, via telegram, chose Send", Attempt: attempt}
|
||||
req.Decided = &claim
|
||||
rev, err := sends.Create(req.ID, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return req, claim, rev, sends
|
||||
}
|
||||
|
||||
// 1198/AC1: given a claimed send the server accepts, when sent, then sent is recorded, mail.send.sent is
|
||||
// emitted (send id, account, outcome in words; no recipient, subject or body), and only then — by
|
||||
// construction, since the caller acknowledges only once this call returns — the message is acknowledged.
|
||||
func TestRecordOutcomeOnSuccessRecordsSentAndEmitsIt(t *testing.T) {
|
||||
req, claimed, rev, sends := outcomeTestClaim(t, 1)
|
||||
emit := &memEmit{}
|
||||
d, action, _, err := recordOutcome(sends, emit.emit, req, claimed, rev, nil, judgedAt.Add(2*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("recordOutcome: %v", err)
|
||||
}
|
||||
if d.Outcome != "sent" {
|
||||
t.Fatalf("outcome: %+v", d)
|
||||
}
|
||||
if action != actionAck {
|
||||
t.Fatalf("action: %v, want actionAck", action)
|
||||
}
|
||||
kept, _, ok, err := getSend(sends, req.ID)
|
||||
if err != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "sent" {
|
||||
t.Fatalf("not kept as sent: %v %v %+v", err, ok, kept.Decided)
|
||||
}
|
||||
if len(emit.calls) != 1 || emit.calls[0].eventType != MailSendSent || emit.calls[0].id != req.ID {
|
||||
t.Fatalf("emitted: %+v", emit.calls)
|
||||
}
|
||||
body := emit.calls[0].body
|
||||
if body.Send != req.ID || body.Account != "mesh" || body.Outcome == "" {
|
||||
t.Fatalf("the event's body: %+v", body)
|
||||
}
|
||||
whole := body.Send + body.Account + body.Outcome
|
||||
for _, forbidden := range []string{"ops@example.org", "s", "body"} {
|
||||
if forbidden == "s" {
|
||||
continue // too short to search for meaningfully without false positives in the id/account
|
||||
}
|
||||
if strings.Contains(whole, forbidden) {
|
||||
t.Fatalf("the emitted body carries %q: %+v", forbidden, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1198/AC2: given a not-taken failure on attempt n (n < 5), then retrying is recorded with attempt, reason
|
||||
// and next attempt, and the caller is told to ask the bus to deliver it again after 1, 5, 15, 30 minutes
|
||||
// for n = 1..4; on the fifth, failed is recorded and mail.send.failed emitted.
|
||||
func TestRecordOutcomeOnNotTakenRetriesUpToTheFourthAttempt(t *testing.T) {
|
||||
for n, want := range map[int]time.Duration{1: time.Minute, 2: 5 * time.Minute, 3: 15 * time.Minute, 4: 30 * time.Minute} {
|
||||
req, claimed, rev, sends := outcomeTestClaim(t, n)
|
||||
emit := &memEmit{}
|
||||
now := judgedAt.Add(2 * time.Minute)
|
||||
d, action, delay, err := recordOutcome(sends, emit.emit, req, claimed, rev, &SendError{Outcome: NotTaken, Err: errors.New("no connection")}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("attempt %d: recordOutcome: %v", n, err)
|
||||
}
|
||||
if d.Outcome != "retrying" || d.Attempt != n {
|
||||
t.Fatalf("attempt %d: outcome %+v", n, d)
|
||||
}
|
||||
if !d.NextAttempt.Equal(now.Add(want)) {
|
||||
t.Fatalf("attempt %d: next attempt %v, want %v", n, d.NextAttempt, now.Add(want))
|
||||
}
|
||||
if action != actionRetry || delay != want {
|
||||
t.Fatalf("attempt %d: action %v, delay %v, want actionRetry, %v", n, action, delay, want)
|
||||
}
|
||||
if len(emit.calls) != 0 {
|
||||
t.Fatalf("attempt %d: retrying emitted something: %+v", n, emit.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordOutcomeOnNotTakenFailsOnTheFifthAttempt(t *testing.T) {
|
||||
req, claimed, rev, sends := outcomeTestClaim(t, MostAttempts)
|
||||
emit := &memEmit{}
|
||||
d, action, _, err := recordOutcome(sends, emit.emit, req, claimed, rev, &SendError{Outcome: NotTaken, Err: errors.New("no connection")},
|
||||
judgedAt.Add(2*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("recordOutcome: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(d.Outcome, "failed: ") || d.Attempt != MostAttempts {
|
||||
t.Fatalf("outcome: %+v", d)
|
||||
}
|
||||
if action != actionTerm {
|
||||
t.Fatalf("action: %v, want actionTerm", action)
|
||||
}
|
||||
if len(emit.calls) != 1 || emit.calls[0].eventType != MailSendFailed {
|
||||
t.Fatalf("emitted: %+v", emit.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1198/AC3: given a refusal, then failed is recorded with the reason, mail.send.failed emitted, and the
|
||||
// message terminated; given not proven, the same with not proven.
|
||||
func TestRecordOutcomeOnRefusedRecordsFailedAndTerminates(t *testing.T) {
|
||||
req, claimed, rev, sends := outcomeTestClaim(t, 1)
|
||||
emit := &memEmit{}
|
||||
d, action, _, err := recordOutcome(sends, emit.emit, req, claimed, rev, &SendError{Outcome: Refused, Err: errors.New("550 no such mailbox")},
|
||||
judgedAt.Add(2*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("recordOutcome: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(d.Outcome, "failed: ") || !strings.Contains(d.Outcome, "550 no such mailbox") {
|
||||
t.Fatalf("outcome: %+v", d)
|
||||
}
|
||||
if action != actionTerm {
|
||||
t.Fatalf("action: %v, want actionTerm", action)
|
||||
}
|
||||
if len(emit.calls) != 1 || emit.calls[0].eventType != MailSendFailed || emit.calls[0].body.Outcome != d.Outcome {
|
||||
t.Fatalf("emitted: %+v", emit.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordOutcomeOnNotProvenRecordsNotProvenAndTerminates(t *testing.T) {
|
||||
req, claimed, rev, sends := outcomeTestClaim(t, 1)
|
||||
emit := &memEmit{}
|
||||
d, action, _, err := recordOutcome(sends, emit.emit, req, claimed, rev,
|
||||
&SendError{Outcome: NotProven, Err: errors.New("connection lost after the data was written")}, judgedAt.Add(2*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("recordOutcome: %v", err)
|
||||
}
|
||||
if d.Outcome != "not proven" {
|
||||
t.Fatalf("outcome: %+v", d)
|
||||
}
|
||||
if action != actionTerm {
|
||||
t.Fatalf("action: %v, want actionTerm", action)
|
||||
}
|
||||
if len(emit.calls) != 1 || emit.calls[0].eventType != MailSendFailed || emit.calls[0].body.Outcome != "not proven" {
|
||||
t.Fatalf("emitted: %+v", emit.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// review round 1 finding 3: a failed write of the tells-pending index does not stop the failed decision
|
||||
// itself from being recorded and emitted — only the index write is reported as failing.
|
||||
func TestRecordOutcomeStillRecordsFailedWhenTheTellsPendingIndexCannotBeWritten(t *testing.T) {
|
||||
req, claimed, rev, sends := outcomeTestClaim(t, 1)
|
||||
sends.failKey, sends.failErr = tellsPendingKey, errors.New("the store is down")
|
||||
emit := &memEmit{}
|
||||
d, action, _, err := recordOutcome(sends, emit.emit, req, claimed, rev, &SendError{Outcome: Refused, Err: errors.New("550 no such mailbox")},
|
||||
judgedAt.Add(2*time.Minute))
|
||||
if err == nil {
|
||||
t.Fatalf("the index failure was not reported")
|
||||
}
|
||||
if !strings.HasPrefix(d.Outcome, "failed: ") || action != actionTerm {
|
||||
t.Fatalf("outcome %+v, action %v", d, action)
|
||||
}
|
||||
kept, _, ok, kerr := getSend(sends, req.ID)
|
||||
if kerr != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != d.Outcome {
|
||||
t.Fatalf("the decision itself was not kept despite the index failing: %v %v %+v", kerr, ok, kept.Decided)
|
||||
}
|
||||
if len(emit.calls) != 1 {
|
||||
t.Fatalf("emitted %d time(s) despite the index failing", len(emit.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// review round 1 finding 5: if writing "sent" cannot be kept (even retried on a changed revision), the
|
||||
// message is never acknowledged and mail.send.sent is never emitted — a mail actually sent is never
|
||||
// silently announced successful when the record never actually carried it.
|
||||
func TestRecordOutcomeOnSentDoesNotAckOrEmitWhenTheWriteCannotBeKept(t *testing.T) {
|
||||
req, claimed, rev, sends := outcomeTestClaim(t, 1)
|
||||
sends.failKey, sends.failErr = req.ID, errors.New("the store is down")
|
||||
emit := &memEmit{}
|
||||
|
||||
d, action, _, err := recordOutcome(sends, emit.emit, req, claimed, rev, nil, judgedAt.Add(2*time.Minute))
|
||||
if err == nil {
|
||||
t.Fatalf("the write failure was not reported")
|
||||
}
|
||||
if action != actionLeave {
|
||||
t.Fatalf("action: %v, want actionLeave (left to the bus, never acked)", action)
|
||||
}
|
||||
if len(emit.calls) != 0 {
|
||||
t.Fatalf("emitted mail.send.sent despite the write never being kept: %+v", emit.calls)
|
||||
}
|
||||
// The kept record is unaffected: still "sending", as claimQueuedSend originally left it.
|
||||
kept, _, ok, kerr := getSend(sends, req.ID)
|
||||
if kerr != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "sending" {
|
||||
t.Fatalf("the record changed despite the write failing: %v %v %+v (returned %+v)", kerr, ok, kept.Decided, d)
|
||||
}
|
||||
}
|
||||
|
||||
// Same finding, for a failed outcome (refused): the message is left to the bus, never terminated, and
|
||||
// mail.send.failed is never emitted, when the write itself cannot be kept.
|
||||
func TestRecordOutcomeOnRefusedDoesNotTerminateOrEmitWhenTheWriteCannotBeKept(t *testing.T) {
|
||||
req, claimed, rev, sends := outcomeTestClaim(t, 1)
|
||||
sends.failKey, sends.failErr = req.ID, errors.New("the store is down")
|
||||
emit := &memEmit{}
|
||||
|
||||
_, action, _, err := recordOutcome(sends, emit.emit, req, claimed, rev, &SendError{Outcome: Refused, Err: errors.New("550 no such mailbox")},
|
||||
judgedAt.Add(2*time.Minute))
|
||||
if err == nil {
|
||||
t.Fatalf("the write failure was not reported")
|
||||
}
|
||||
if action != actionLeave {
|
||||
t.Fatalf("action: %v, want actionLeave", action)
|
||||
}
|
||||
if len(emit.calls) != 0 {
|
||||
t.Fatalf("emitted mail.send.failed despite the write never being kept: %+v", emit.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1198/AC1 (review round 2): a store that changes the revision between the claim and the outcome — another
|
||||
// writer's decision landing in between — is retried once with the fresh revision; recordOutcome's own
|
||||
// decision, once it does land, is what is kept, acked and emitted, never the other writer's state read
|
||||
// back with a nil error mistaken for success.
|
||||
func TestRecordOutcomeRetriesOnceWhenTheRevisionChangedBetweenClaimAndOutcome(t *testing.T) {
|
||||
req, claimed, rev, sends := outcomeTestClaim(t, 1)
|
||||
racy := &raceOnFirstUpdate{SendStore: sends, race: func() {
|
||||
// A different delivery's own claim lands first, bumping the revision without ever finishing this
|
||||
// call's own write — settleSend (via updateDecision) answers that with revision 0 and a nil error.
|
||||
r, rv, ok, err := getSend(sends, req.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("racing write could not read the send: %v %v", err, ok)
|
||||
}
|
||||
if _, _, err := settleSend(sends, r, rv, SendDecision{At: judgedAt, Outcome: "sending", Attempt: 2}); err != nil {
|
||||
t.Fatalf("racing write: %v", err)
|
||||
}
|
||||
}}
|
||||
emit := &memEmit{}
|
||||
|
||||
d, action, _, err := recordOutcome(racy, emit.emit, req, claimed, rev, nil, judgedAt.Add(2*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("recordOutcome: %v", err)
|
||||
}
|
||||
if d.Outcome != "sent" || action != actionAck {
|
||||
t.Fatalf("outcome %+v, action %v, want sent/actionAck once retried with the fresh revision", d, action)
|
||||
}
|
||||
if len(emit.calls) != 1 || emit.calls[0].eventType != MailSendSent || emit.calls[0].body.Outcome != "sent" {
|
||||
t.Fatalf("emitted: %+v", emit.calls)
|
||||
}
|
||||
kept, _, ok, kerr := getSend(sends, req.ID)
|
||||
if kerr != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "sent" {
|
||||
t.Fatalf("the kept record is not this call's own decision: %v %v %+v", kerr, ok, kept.Decided)
|
||||
}
|
||||
}
|
||||
|
||||
// Same finding: when the race repeats on the retry too (a second writer keeps landing first), recordOutcome
|
||||
// gives up rather than looping or mistaking the other writer's state for its own — actionLeave, nothing
|
||||
// emitted, the error reported.
|
||||
func TestRecordOutcomeGivesUpWhenTheRevisionKeepsChanging(t *testing.T) {
|
||||
req, claimed, rev, sends := outcomeTestClaim(t, 1)
|
||||
always := &raceEveryUpdate{SendStore: sends, race: func() {
|
||||
r, rv, ok, err := getSend(sends, req.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("racing write could not read the send: %v %v", err, ok)
|
||||
}
|
||||
if _, _, err := settleSend(sends, r, rv, SendDecision{At: judgedAt, Outcome: "sending", Attempt: r.Decided.Attempt + 1}); err != nil {
|
||||
t.Fatalf("racing write: %v", err)
|
||||
}
|
||||
}}
|
||||
emit := &memEmit{}
|
||||
|
||||
_, action, _, err := recordOutcome(always, emit.emit, req, claimed, rev, nil, judgedAt.Add(2*time.Minute))
|
||||
if err == nil {
|
||||
t.Fatalf("a persistently changing revision was not reported")
|
||||
}
|
||||
if action != actionLeave {
|
||||
t.Fatalf("action: %v, want actionLeave", action)
|
||||
}
|
||||
if len(emit.calls) != 0 {
|
||||
t.Fatalf("emitted despite never keeping its own write: %+v", emit.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// raceEveryUpdate performs the competing write before every Update call, not only the first — simulating a
|
||||
// revision that keeps changing out from under every retry (novox/hq review round 2, issue 1198).
|
||||
type raceEveryUpdate struct {
|
||||
SendStore
|
||||
race func()
|
||||
}
|
||||
|
||||
func (r *raceEveryUpdate) Update(key string, value any, revision uint64) (uint64, error) {
|
||||
r.race()
|
||||
return r.SendStore.Update(key, value, revision)
|
||||
}
|
||||
@@ -395,14 +395,230 @@ func (s *PgStore) Get(ctx context.Context, account, folder string, uid uint32) (
|
||||
return m, err == nil, err
|
||||
}
|
||||
|
||||
// addressFilterSQL is 1228/AC2, 1228/AC4, 1228/AC6(cc): an EXISTS over the jsonb array column (recipients or
|
||||
// copied), matched against one already-lowercased parameter either as a whole address or, with no "@", as a
|
||||
// domain (every address under it): right(address, length(domain)+1) = '@'||domain, an exact substring compare,
|
||||
// not LIKE, so a filter holding "%" or "_" is never a wildcard (issue 1228, review round 1 finding 4: a filter
|
||||
// from:["%"] matched every mail in Postgres and none in memStore, since memStore's strings.HasSuffix never
|
||||
// treats "%" specially). column names a fixed set the Go code chooses, never caller input.
|
||||
func addressFilterSQL(column, param string) string {
|
||||
return fmt.Sprintf("exists (select 1 from jsonb_array_elements(%s) el where lower(el->>'address') = %s or "+
|
||||
"right(lower(el->>'address'), length(%s) + 1) = '@' || %s)", column, param, param, param)
|
||||
}
|
||||
|
||||
// addressFilterSQLAny is 1228/AC3; 1230/AC11 ("several domains"): an EXISTS over column (sender), matched
|
||||
// against any of filters, OR'd (each already-lowercased, each either a whole address or, with no "@", a domain),
|
||||
// the same exact substring compare as addressFilterSQL, never LIKE. The whole list reaches SQL as one
|
||||
// parameter, a text[]; never concatenated.
|
||||
func addressFilterSQLAny(column string, filters []string, param func(any) string) string {
|
||||
lowered := make([]string, len(filters))
|
||||
for i, f := range filters {
|
||||
lowered[i] = strings.ToLower(f)
|
||||
}
|
||||
p := param(lowered)
|
||||
return fmt.Sprintf("exists (select 1 from jsonb_array_elements(%s) el, unnest(%s::text[]) f where "+
|
||||
"lower(el->>'address') = f or right(lower(el->>'address'), length(f) + 1) = '@' || f)", column, p)
|
||||
}
|
||||
|
||||
// wordFilterSQL is 1229/AC13 (one column: SubjectWords or BodyWords); AC5, AC6(words), AC12, AC15 (review round
|
||||
// 1 finding 3: columns holds both subject and body for Words, so a word counts in either — "the word in subject
|
||||
// or body"). One condition per word of the list, each a whole-word, case-insensitive match (Postgres's \y, its
|
||||
// own word boundary) against any one of columns, so a hyphenated word such as "4711-882" is never matched by
|
||||
// only its digits (AC12); the per-word conditions join with "and" ("all", the default) or "or" ("any"). Empty
|
||||
// answers "" (no filter). The word itself always reaches SQL as param's parameter, never concatenated; only its
|
||||
// \y anchors and the column names (a fixed set the Go code chooses) are fixed SQL text.
|
||||
func wordFilterSQL(columns []string, words []string, any bool, param func(any) string) string {
|
||||
if len(words) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(words))
|
||||
for _, w := range words {
|
||||
p := param(quoteRegex(w))
|
||||
cols := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
cols[i] = fmt.Sprintf(`%s ~* ('\y' || %s || '\y')`, col, p)
|
||||
}
|
||||
parts = append(parts, "("+strings.Join(cols, " or ")+")")
|
||||
}
|
||||
op := " and "
|
||||
if any {
|
||||
op = " or "
|
||||
}
|
||||
return "(" + strings.Join(parts, op) + ")"
|
||||
}
|
||||
|
||||
// quoteRegex escapes a word for Postgres's regex operators (ARE, a superset of POSIX ERE): the word is always
|
||||
// matched literally, never as a pattern of its own, mirroring Go's regexp.QuoteMeta used by memStore's own match.
|
||||
var quoteRegexEscaper = strings.NewReplacer(
|
||||
`\`, `\\`, ".", `\.`, "+", `\+`, "*", `\*`, "?", `\?`, "(", `\(`, ")", `\)`,
|
||||
"[", `\[`, "]", `\]`, "{", `\{`, "}", `\}`, "^", `\^`, "$", `\$`, "|", `\|`)
|
||||
|
||||
func quoteRegex(word string) string { return quoteRegexEscaper.Replace(word) }
|
||||
|
||||
// globToLike turns a shell glob ("*" and "?") into a SQL LIKE pattern, escaping LIKE's own metacharacters first
|
||||
// (issue 1229, AC5, AC9: an attachment name pattern such as "*.pdf").
|
||||
func globToLike(pattern string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range pattern {
|
||||
switch r {
|
||||
case '*':
|
||||
b.WriteByte('%')
|
||||
case '?':
|
||||
b.WriteByte('_')
|
||||
case '%', '_', '\\':
|
||||
b.WriteByte('\\')
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// lowerStrings is 1231/AC14 (review round 1 finding 1): sentFolderNames, lowercased once, to bind as a single
|
||||
// text[] parameter against lower(m2.folder) = any(...).
|
||||
func lowerStrings(ss []string) []string {
|
||||
out := make([]string, len(ss))
|
||||
for i, s := range ss {
|
||||
out[i] = strings.ToLower(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// searchWhere is every filter of Query (everything but its order and its limit), shared by Search and
|
||||
// GroupBySender so the two can never drift apart; every value from the query reaches SQL as one of args'
|
||||
// parameters, never concatenated (only fixed column names and SQL text are built as strings).
|
||||
func searchWhere(q Query) (whereSQL string, args []any) {
|
||||
param := func(v any) string {
|
||||
args = append(args, v)
|
||||
return fmt.Sprintf("$%d", len(args))
|
||||
}
|
||||
where := []string{"account = any(" + param(q.Accounts) + ")"}
|
||||
if q.Folder != "" {
|
||||
where = append(where, "folder = "+param(q.Folder))
|
||||
}
|
||||
if q.Text != "" {
|
||||
where = append(where, "search @@ plainto_tsquery('simple'::regconfig, "+param(q.Text)+")")
|
||||
}
|
||||
if len(q.From) > 0 {
|
||||
where = append(where, addressFilterSQLAny("sender", q.From, param))
|
||||
}
|
||||
if q.To != "" {
|
||||
where = append(where, addressFilterSQL("recipients", param(strings.ToLower(q.To))))
|
||||
}
|
||||
if q.Cc != "" {
|
||||
where = append(where, addressFilterSQL("copied", param(strings.ToLower(q.Cc))))
|
||||
}
|
||||
if !q.OnOrAfter.IsZero() {
|
||||
where = append(where, "date >= "+param(q.OnOrAfter))
|
||||
}
|
||||
if !q.Before.IsZero() {
|
||||
where = append(where, "date < "+param(q.Before))
|
||||
}
|
||||
if c := wordFilterSQL([]string{"subject"}, q.SubjectWords, q.SubjectWordsAny, param); c != "" {
|
||||
where = append(where, c)
|
||||
}
|
||||
if c := wordFilterSQL([]string{"body"}, q.BodyWords, q.BodyWordsAny, param); c != "" {
|
||||
where = append(where, c)
|
||||
}
|
||||
// 1229/AC5, AC6(words), AC12, AC15 (review round 1 finding 3): a word found in subject or body.
|
||||
if c := wordFilterSQL([]string{"subject", "body"}, q.Words, q.WordsAny, param); c != "" {
|
||||
where = append(where, c)
|
||||
}
|
||||
if q.HasAttachment {
|
||||
where = append(where, "jsonb_array_length(attachments) > 0")
|
||||
}
|
||||
if q.AttachmentType != "" || q.AttachmentNamePattern != "" {
|
||||
var conds []string
|
||||
if q.AttachmentType != "" {
|
||||
conds = append(conds, "lower(el->>'type') = "+param(strings.ToLower(q.AttachmentType)))
|
||||
}
|
||||
if q.AttachmentNamePattern != "" {
|
||||
conds = append(conds, "lower(el->>'name') like "+param(globToLike(strings.ToLower(q.AttachmentNamePattern))))
|
||||
}
|
||||
where = append(where, "exists (select 1 from jsonb_array_elements(attachments) el where "+strings.Join(conds, " or ")+")")
|
||||
}
|
||||
if q.Unread != nil {
|
||||
where = append(where, fmt.Sprintf("(%s = any(flags)) = %s", param(`\Seen`), param(!*q.Unread)))
|
||||
}
|
||||
if q.InReplyTo != "" {
|
||||
where = append(where, "in_reply_to = "+param(q.InReplyTo))
|
||||
}
|
||||
if q.FromSomeoneIWroteTo {
|
||||
// 1231/AC14 (review round 1 finding 1): some sender address of this message is a recipient (to or cc) of
|
||||
// a mail, in the SAME account (m2.account = message.account), from a folder named one of sentFolderNames
|
||||
// ("Sent" for mesh and mediahuis/mailu, "[Gmail]/Sent Mail" for gmail; a future account naming it a third
|
||||
// way is the tool layer's "say what you chose", not this query). The list reaches SQL as one parameter.
|
||||
where = append(where, fmt.Sprintf(`exists (select 1 from jsonb_array_elements(sender) s where exists (
|
||||
select 1 from message m2, jsonb_array_elements(m2.recipients || m2.copied) r
|
||||
where m2.account = message.account and lower(m2.folder) = any(%s) and lower(r->>'address') = lower(s->>'address')))`,
|
||||
param(lowerStrings(sentFolderNames))))
|
||||
}
|
||||
return strings.Join(where, " and "), args
|
||||
}
|
||||
|
||||
// orderColumns is 1230's fixed Go map from an order-by name to its (safe, never caller-built) SQL expression;
|
||||
// unknown or empty answers "date" — the tool refuses an unknown order-by before the store is ever asked (AC16).
|
||||
var orderColumns = map[string]string{
|
||||
"date": "date",
|
||||
"sender": "lower(coalesce(sender->0->>'address', ''))",
|
||||
"subject": "subject",
|
||||
"size": "size",
|
||||
}
|
||||
|
||||
func orderExpr(orderBy string) string {
|
||||
if e, ok := orderColumns[orderBy]; ok {
|
||||
return e
|
||||
}
|
||||
return orderColumns["date"]
|
||||
}
|
||||
|
||||
// dedupedMessagesSQL is 1248/AC1, AC2, AC4-AC7: a FROM-clause subquery, aliased "message" so the caller needs no
|
||||
// further change, that answers each (account, message-id) once among the rows whereSQL matches: grouped by
|
||||
// account and message_id, except an empty message_id (AC5) is never grouped with anything else — each such row
|
||||
// gets its own one-row group, keyed by its own (account, folder, uid) instead, which is already unique. Within a
|
||||
// group the copy outside archiveFolderNames wins (AC1); only when every copy is archived does the archive copy
|
||||
// answer (AC2). When a message-id sits in two non-archive folders too (e.g. INBOX and a label folder such as
|
||||
// "INBOX/Telenet"; review round 1 finding 1), folder ascending then uid ascending breaks the tie — the same
|
||||
// fixed rule memStore's dedupeBetter (store.go) uses, so the two stores never pick a different copy. Callers
|
||||
// skip this (keep the plain "message where ...") when a folder is named (AC3): every mail of that folder
|
||||
// answers, never merged with anything else.
|
||||
func dedupedMessagesSQL(whereSQL string, param func(any) string) string {
|
||||
archiveParam := param(lowerStrings(archiveFolderNames))
|
||||
return `(select distinct on (account, dedup_key) ` + messageColumns + ` from (
|
||||
select *,
|
||||
case when message_id = '' then 'u:' || folder || ':' || uid::text else message_id end as dedup_key,
|
||||
(lower(folder) = any(` + archiveParam + `)) as dedup_archived
|
||||
from message where ` + whereSQL + `
|
||||
) m order by account, dedup_key, dedup_archived asc, folder asc, uid asc) message`
|
||||
}
|
||||
|
||||
func (s *PgStore) Search(ctx context.Context, q Query) ([]Message, error) {
|
||||
limit := q.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `select `+messageColumns+` from message
|
||||
where account = any($1) and ($2 = '' or folder = $2) and search @@ plainto_tsquery('simple'::regconfig, $3)
|
||||
order by date desc limit $4`, q.Accounts, q.Folder, q.Text, limit)
|
||||
whereSQL, args := searchWhere(q)
|
||||
param := func(v any) string {
|
||||
args = append(args, v)
|
||||
return fmt.Sprintf("$%d", len(args))
|
||||
}
|
||||
// 1230 (AC7, AC11, AC17's schema): order by the chosen expression, ascending unless desc; an empty OrderBy
|
||||
// keeps the newest-first default (AC1) regardless of OrderDesc. Ties always break ascending on
|
||||
// (account, folder, uid), so the order is stable.
|
||||
dir := "asc"
|
||||
if q.OrderBy == "" || q.OrderDesc {
|
||||
dir = "desc"
|
||||
}
|
||||
// 1248/AC1-AC7: with no folder named, order/limit/group-by apply to the de-duplicated set (dedupedMessagesSQL);
|
||||
// a named folder (AC3) keeps the plain scan.
|
||||
fromSQL := "message where " + whereSQL
|
||||
if q.Folder == "" {
|
||||
fromSQL = dedupedMessagesSQL(whereSQL, param)
|
||||
}
|
||||
sqlText := "select " + messageColumns + " from " + fromSQL +
|
||||
" order by " + orderExpr(q.OrderBy) + " " + dir + ", account, folder, uid limit " + param(limit)
|
||||
rows, err := s.pool.Query(ctx, sqlText, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -418,6 +634,62 @@ func (s *PgStore) Search(ctx context.Context, q Query) ([]Message, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Newest is 1282: one account's newest n mails, via Search's own ordering (newest first) and deduplication (no
|
||||
// folder named, so dedupedMessagesSQL applies — the same copy Search itself would answer). Covered only by the
|
||||
// integration tests (issue 1244): this package's unit tests use memStore.
|
||||
func (s *PgStore) Newest(ctx context.Context, account string, n int) ([]Message, error) {
|
||||
return s.Search(ctx, Query{Accounts: []string{account}, Limit: n})
|
||||
}
|
||||
|
||||
// GroupBySender is 1230/AC10: one line per sender among the messages searchWhere's filters match, by count
|
||||
// descending (address ascending to break ties), Limit bounding the number of lines.
|
||||
func (s *PgStore) GroupBySender(ctx context.Context, q Query) ([]SenderGroup, error) {
|
||||
limit := q.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
whereSQL, args := searchWhere(q)
|
||||
param := func(v any) string {
|
||||
args = append(args, v)
|
||||
return fmt.Sprintf("$%d", len(args))
|
||||
}
|
||||
// 1248/AC4: with no folder named, group by sender over the de-duplicated set (dedupedMessagesSQL), so a mail
|
||||
// held in two folders counts once; a named folder keeps the plain scan, unchanged.
|
||||
fromSQL, groupWhere := "message", whereSQL
|
||||
if q.Folder == "" {
|
||||
fromSQL, groupWhere = dedupedMessagesSQL(whereSQL, param), "true"
|
||||
}
|
||||
// Review round 1 finding 6: grouped by lower(address) (not case-sensitively, as a mixed-case repeat of the
|
||||
// same address would otherwise split into two lines); name and address are the newest mail's own
|
||||
// (array_agg ordered by date, its first element), never max()'s lexicographically largest, which is neither.
|
||||
// The outer select re-sorts by lower(address): Postgres resolves a SELECT alias used bare in order by, but
|
||||
// not wrapped in a function, so the grouping is wrapped in its own subquery first.
|
||||
sqlText := `select address, name, count, newest from (
|
||||
select (array_agg(el->>'address' order by date desc))[1] as address,
|
||||
(array_agg(el->>'name' order by date desc))[1] as name, count(*) as count, max(date) as newest
|
||||
from ` + fromSQL + `, jsonb_array_elements(sender) el where ` + groupWhere + ` group by lower(el->>'address')
|
||||
) grouped order by count desc, lower(address) asc limit ` + param(limit)
|
||||
rows, err := s.pool.Query(ctx, sqlText, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SenderGroup
|
||||
for rows.Next() {
|
||||
var g SenderGroup
|
||||
var name *string
|
||||
if err := rows.Scan(&g.Address, &name, &g.Count, &g.Newest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name != nil {
|
||||
g.Name = *name
|
||||
}
|
||||
g.Newest = g.Newest.UTC()
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) Folders(ctx context.Context, account string) ([]Folder, error) {
|
||||
rows, err := s.pool.Query(ctx, `select name, uidvalidity, messages, synced from folder where account = $1 order by name`, account)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
// The SDK pin a process's own bus connection needs (novox/hq ADR 0436 §4, issue 1172): mesh-mail's go.mod
|
||||
// requires mesh-sdk go at the release-2 commit (main 34392e7) and go 1.26.0, and go/process is importable.
|
||||
// The blank import below is what proves importability at compile time; go vet (run before every commit, per
|
||||
// the context file) already fails the build if it is not.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "git.novox.be/novox/mesh-sdk/go/process"
|
||||
)
|
||||
|
||||
// 1172/AC1: given mesh-mail's go.mod, when built, then it requires mesh-sdk go at
|
||||
// v0.1.14-0.20261019062050-34392e74916c and go 1.26.0, and go/process is importable (the import above).
|
||||
func TestGoModPinsTheSDKsProcessRelease(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../go.mod")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mod := string(raw)
|
||||
const wantSDK = "git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c"
|
||||
if !strings.Contains(mod, wantSDK) {
|
||||
t.Fatalf("go.mod does not require %q:\n%s", wantSDK, mod)
|
||||
}
|
||||
if !strings.Contains(mod, "go 1.26.0") {
|
||||
t.Fatalf("go.mod does not declare go 1.26.0:\n%s", mod)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
package main
|
||||
|
||||
// Fixtures and store-level tests for mail_search's filters (issues 1228-1232, novox/hq mesh-catalog.mail-search).
|
||||
// Built once as fixture Messages (store.go's own type) across two accounts (mesh, gmail) and the folders INBOX,
|
||||
// Sent, Junk, then reused by every issue's tests, run through eachStore (the memory store and Postgres,
|
||||
// kit_test.go:170-254). "today" for these fixtures is fixed at searchNow, never time.Now(), so a run on any day
|
||||
// answers the same thing.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// searchNow is "today" for the fixtures below (AC2's "on-or-after today", AC7's "last month", AC9's "this week's
|
||||
// Monday", AC11's "90 days ago"): a fixed instant, not time.Now(), so the tests are deterministic.
|
||||
var searchNow = time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// searchFixtures is one small mailbox: two accounts, three folders each. Every field's shape is store.go's own
|
||||
// Message (store.go:33-50): From/To/Cc are []Person{Name, Address} (store.go:17-20, as imap.go's "people" builds
|
||||
// them from an envelope, imap.go:507-515); Attachments are []AttachmentRef{Bucket, Key, Size, Type, Name}
|
||||
// (store.go:24-30); Flags are []string of IMAP flag names such as "\Seen" (store.go:46, imap.go:517-527); Date is
|
||||
// the parsed Date header (store.go:40, imap.go:483-485); MessageID/InReplyTo are the Message-Id and In-Reply-To
|
||||
// headers verbatim (store.go:38-39).
|
||||
func searchFixtures() []Message {
|
||||
at := func(h int, m int) time.Time { return time.Date(2026, 10, 19, h, m, 0, 0, time.UTC) }
|
||||
return []Message{
|
||||
// mesh/INBOX ------------------------------------------------------------------------------------------------
|
||||
{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "<invoice1@mesh>", Subject: "October invoice",
|
||||
Date: at(9, 0), From: []Person{{Name: "Bob", Address: "bob@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
|
||||
Flags: []string{}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a1", Size: 1200, Type: "application/pdf", Name: "invoice.pdf"}},
|
||||
Body: "please settle the invoice, due at month end", Size: 1500},
|
||||
{Account: "mesh", Folder: "INBOX", UID: 2, MessageID: "<renov1@mesh>", Subject: "renovatie plannen",
|
||||
Date: time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC), From: []Person{{Name: "Alice", Address: "alice@example.org"}},
|
||||
To: []Person{{Address: "bob@example.org"}}, Cc: []Person{{Address: "jschoubben@novox.be"}},
|
||||
Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{}, Body: "over de renovatie van de keuken", Size: 900},
|
||||
{Account: "mesh", Folder: "INBOX", UID: 3, MessageID: "<renov2@mesh>", Subject: "renovatie herinnering",
|
||||
Date: time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC), From: []Person{{Name: "Alice", Address: "alice@example.org"}},
|
||||
To: []Person{{Address: "jschoubben@novox.be"}}, Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{},
|
||||
Body: "nog een vraag over de renovatie", Size: 800},
|
||||
{Account: "mesh", Folder: "INBOX", UID: 4, MessageID: "<sec1@mesh>", Subject: "security alert",
|
||||
Date: time.Date(2026, 10, 15, 7, 0, 0, 0, time.UTC), From: []Person{{Name: "Carol", Address: "carol@google.com"}},
|
||||
To: []Person{{Address: "jschoubben@novox.be"}}, Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{},
|
||||
Body: "someone logged into your account from a new device", Size: 600},
|
||||
{Account: "mesh", Folder: "INBOX", UID: 5, MessageID: "<cal1@mesh>", Subject: "team meeting invite",
|
||||
Date: at(8, 0), From: []Person{{Name: "Dave", Address: "dave@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
|
||||
Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a2", Size: 300, Type: "text/calendar", Name: "meeting.ics"}},
|
||||
Body: "please join the planning meeting", Size: 700},
|
||||
{Account: "mesh", Folder: "INBOX", UID: 6, MessageID: "<reply1@mesh>", InReplyTo: "<sent1@mesh>", Subject: "Re: quick note",
|
||||
Date: at(12, 30), From: []Person{{Name: "Bob", Address: "bob@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
|
||||
Flags: []string{}, Attachments: []AttachmentRef{}, Body: "sounds good, thanks for the note", Size: 400},
|
||||
// 1229/AC5: from a domain (google.com), has-attachment, name *.pdf and the word "factuur" — in the
|
||||
// subject only (review round 1 finding 3), so Words's "subject or body" is genuinely exercised, not
|
||||
// merely a body match that would pass even with a subject-only bug.
|
||||
{Account: "mesh", Folder: "INBOX", UID: 7, MessageID: "<factuur1@mesh>", Subject: "the factuur for Q3",
|
||||
Date: at(13, 0), From: []Person{{Name: "Erik", Address: "erik@google.com"}}, To: []Person{{Address: "jschoubben@novox.be"}},
|
||||
Flags: []string{}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a4", Size: 200, Type: "application/pdf", Name: "factuur.pdf"}},
|
||||
Body: "see the attached file, due on receipt", Size: 850},
|
||||
// 1229/AC12: a word with digits and a dash ("4711-882"), matched whole, in the subject only (review
|
||||
// round 1 finding 3).
|
||||
{Account: "mesh", Folder: "INBOX", UID: 8, MessageID: "<order1@mesh>", Subject: "order 4711-882 confirmed",
|
||||
Date: at(14, 0), From: []Person{{Address: "ops@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
|
||||
Flags: []string{}, Attachments: []AttachmentRef{}, Body: "your order has shipped", Size: 300},
|
||||
// Control for 1229/AC12: "4711" and "882" appear as separate tokens, never hyphenated together.
|
||||
{Account: "mesh", Folder: "INBOX", UID: 9, MessageID: "<order2@mesh>", Subject: "order 4711 shipped, item 882 noted",
|
||||
Date: at(14, 5), From: []Person{{Address: "ops@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
|
||||
Flags: []string{}, Attachments: []AttachmentRef{}, Body: "item 4711 and item 882 are unrelated", Size: 300},
|
||||
// 1229/AC15 (the "any" word list): only "verzekering" is present, not "schade" — in the subject only
|
||||
// (review round 1 finding 3) — with an attachment.
|
||||
{Account: "mesh", Folder: "INBOX", UID: 10, MessageID: "<polis1@mesh>", Subject: "verzekering aanvraag",
|
||||
Date: at(14, 10), From: []Person{{Address: "broker@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
|
||||
Flags: []string{}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a5", Size: 150, Type: "application/pdf", Name: "polis.pdf"}},
|
||||
Body: "nieuwe polis aanvraag", Size: 450},
|
||||
|
||||
// mesh/Sent ---------------------------------------------------------------------------------------------------
|
||||
{Account: "mesh", Folder: "Sent", UID: 1, MessageID: "<sentreply1@mesh>", InReplyTo: "<invoice1@mesh>", Subject: "Re: October invoice",
|
||||
Date: at(10, 0), From: []Person{{Address: "jschoubben@novox.be"}}, To: []Person{{Address: "bob@example.org"}},
|
||||
Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{}, Body: "paid, thank you", Size: 300},
|
||||
{Account: "mesh", Folder: "Sent", UID: 2, MessageID: "<sent1@mesh>", Subject: "quick note",
|
||||
Date: time.Date(2026, 10, 18, 9, 0, 0, 0, time.UTC), From: []Person{{Address: "jschoubben@novox.be"}},
|
||||
To: []Person{{Address: "bob@example.org"}}, Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{}, Body: "just checking in", Size: 250},
|
||||
|
||||
// mesh/Junk ---------------------------------------------------------------------------------------------------
|
||||
{Account: "mesh", Folder: "Junk", UID: 1, MessageID: "<junk1@mesh>", Subject: "special offer just for you",
|
||||
Date: time.Date(2026, 10, 10, 6, 0, 0, 0, time.UTC), From: []Person{{Address: "bob@example.org"}},
|
||||
To: []Person{{Address: "jschoubben@novox.be"}}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "buy now and save big", Size: 500},
|
||||
{Account: "mesh", Folder: "Junk", UID: 2, MessageID: "<junk2@mesh>", Subject: "you won the lottery",
|
||||
Date: time.Date(2026, 10, 10, 6, 5, 0, 0, time.UTC), From: []Person{{Address: "spam@unknown.com"}},
|
||||
To: []Person{{Address: "jschoubben@novox.be"}}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "claim your prize now", Size: 500},
|
||||
|
||||
// gmail/INBOX -------------------------------------------------------------------------------------------------
|
||||
{Account: "gmail", Folder: "INBOX", UID: 1, MessageID: "<gsec1@gmail>", Subject: "security alert for your account",
|
||||
Date: time.Date(2026, 10, 16, 7, 0, 0, 0, time.UTC), From: []Person{{Name: "Google", Address: "security@google.com"}},
|
||||
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{},
|
||||
Body: "new sign-in detected from a new device", Size: 650},
|
||||
{Account: "gmail", Folder: "INBOX", UID: 2, MessageID: "<gschade1@gmail>", Subject: "schade melding woning",
|
||||
Date: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), From: []Person{{Name: "Someone", Address: "someone@example.org"}},
|
||||
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a3", Size: 400, Type: "application/pdf", Name: "schade.pdf"}},
|
||||
Body: "ik heb verzekering nodig voor de schade aan het dak", Size: 1100},
|
||||
// 1229/AC9 (review round 1, "also in this pass"): matches the type-or-name attachment filter by its
|
||||
// type alone — its name does not match the glob.
|
||||
{Account: "gmail", Folder: "INBOX", UID: 3, MessageID: "<cal2@gmail>", Subject: "another invite",
|
||||
Date: time.Date(2026, 10, 19, 9, 0, 0, 0, time.UTC), From: []Person{{Address: "dave@example.org"}},
|
||||
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a6", Size: 300, Type: "text/calendar", Name: "event.bin"}},
|
||||
Body: "a second invite", Size: 700},
|
||||
// 1229/AC9: matches the same filter by its name alone — its type is not text/calendar.
|
||||
{Account: "gmail", Folder: "INBOX", UID: 4, MessageID: "<cal3@gmail>", Subject: "yet another invite",
|
||||
Date: time.Date(2026, 10, 19, 9, 5, 0, 0, time.UTC), From: []Person{{Address: "dave@example.org"}},
|
||||
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a7", Size: 300, Type: "application/octet-stream", Name: "invite.ics"}},
|
||||
Body: "a third invite", Size: 700},
|
||||
|
||||
// gmail/[Gmail]/Sent Mail (review round 1 finding 1: gmail's live folder name for Sent, not "Sent") -----------
|
||||
{Account: "gmail", Folder: "[Gmail]/Sent Mail", UID: 1, MessageID: "<gsentreply1@gmail>", InReplyTo: "<gschade1@gmail>", Subject: "Re: schade melding woning",
|
||||
Date: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), From: []Person{{Address: "me@gmail.example"}},
|
||||
To: []Person{{Address: "someone@example.org"}}, Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{}, Body: "will send the claim form soon", Size: 350},
|
||||
|
||||
// gmail/INBOX, a mail gmail also keeps a copy of in [Gmail]/All Mail (1248/AC1, AC4) ----------------------
|
||||
{Account: "gmail", Folder: "INBOX", UID: 5, MessageID: "<gdup1@gmail>", Subject: "dashboard digest",
|
||||
Date: time.Date(2026, 10, 17, 7, 0, 0, 0, time.UTC), From: []Person{{Name: "Dashboard", Address: "dash@example.org"}},
|
||||
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "weekly digest", Size: 500},
|
||||
|
||||
// gmail/[Gmail]/All Mail (1248/AC1-AC4): gmail's archive, holding a second copy of every mail. UID 100 is
|
||||
// gdup1@gmail's own second copy (same message-id as the INBOX one above); UID 101 is a mail archived out
|
||||
// of the inbox, living only here.
|
||||
{Account: "gmail", Folder: "[Gmail]/All Mail", UID: 100, MessageID: "<gdup1@gmail>", Subject: "dashboard digest",
|
||||
Date: time.Date(2026, 10, 17, 7, 0, 0, 0, time.UTC), From: []Person{{Name: "Dashboard", Address: "dash@example.org"}},
|
||||
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "weekly digest", Size: 500},
|
||||
{Account: "gmail", Folder: "[Gmail]/All Mail", UID: 101, MessageID: "<garchived1@gmail>", Subject: "old newsletter",
|
||||
Date: time.Date(2026, 7, 1, 7, 0, 0, 0, time.UTC), From: []Person{{Name: "Newsletter", Address: "news@example.org"}},
|
||||
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{}, Body: "monthly digest", Size: 400},
|
||||
|
||||
// mesh/Junk (review round 1 finding 2): someone@example.org, written to only by gmail's Sent, not mesh's.
|
||||
{Account: "mesh", Folder: "Junk", UID: 3, MessageID: "<junk3@mesh>", Subject: "a gmail contact's junk mail",
|
||||
Date: time.Date(2026, 10, 11, 6, 0, 0, 0, time.UTC), From: []Person{{Address: "someone@example.org"}},
|
||||
To: []Person{{Address: "jschoubben@novox.be"}}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "unrelated offer", Size: 500},
|
||||
|
||||
// gmail/Junk (review round 1 finding 1): the same someone@example.org, but here it is gmail's own
|
||||
// account, whose "[Gmail]/Sent Mail" did write to this address — so this one must match.
|
||||
{Account: "gmail", Folder: "Junk", UID: 1, MessageID: "<gjunk1@gmail>", Subject: "a junk mail from a gmail contact",
|
||||
Date: time.Date(2026, 10, 11, 6, 0, 0, 0, time.UTC), From: []Person{{Address: "someone@example.org"}},
|
||||
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "unrelated offer", Size: 500},
|
||||
}
|
||||
}
|
||||
|
||||
// addFixtures indexes every fixture message into the store.
|
||||
func addFixtures(t *testing.T, ctx context.Context, st Store) {
|
||||
t.Helper()
|
||||
for _, m := range searchFixtures() {
|
||||
if err := st.Add(ctx, m, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uids(ms []Message) []string {
|
||||
out := make([]string, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
out = append(out, m.MessageID)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sameSet(t *testing.T, got []Message, want ...string) {
|
||||
t.Helper()
|
||||
sort.Strings(want)
|
||||
g := uids(got)
|
||||
if len(g) != len(want) {
|
||||
t.Fatalf("got %v, want %v", g, want)
|
||||
}
|
||||
for i := range g {
|
||||
if g[i] != want[i] {
|
||||
t.Fatalf("got %v, want %v", g, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1228/AC2: account, folder and on-or-after combine (unread is 1229's, so this covers only the date/account/folder
|
||||
// part this issue carries).
|
||||
func TestSearchFiltersByAccountFolderAndOnOrAfter(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "INBOX", OnOrAfter: time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC), Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, got, "<invoice1@mesh>", "<cal1@mesh>", "<reply1@mesh>", "<factuur1@mesh>", "<order1@mesh>", "<order2@mesh>", "<polis1@mesh>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1228/AC3: from <address> and on-or-after 1 January answers only that sender's mails of this year.
|
||||
func TestSearchFiltersByFromAndOnOrAfter(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, From: []string{"bob@example.org"},
|
||||
OnOrAfter: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, got, "<invoice1@mesh>", "<reply1@mesh>", "<junk1@mesh>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1228/AC4: folder Sent, to <address or domain> and a date range; a domain matches every address of it.
|
||||
func TestSearchFiltersByFolderToAndDateRange(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
byAddress, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "Sent", To: "bob@example.org",
|
||||
OnOrAfter: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), Before: time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, byAddress, "<sentreply1@mesh>", "<sent1@mesh>")
|
||||
|
||||
byDomain, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "Sent", To: "example.org",
|
||||
OnOrAfter: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), Before: time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, byDomain, "<sentreply1@mesh>", "<sent1@mesh>")
|
||||
|
||||
// Before Oct 1 excludes both, proving the range's ends are honoured, not only the address.
|
||||
none, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "Sent", To: "bob@example.org",
|
||||
Before: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(none) != 0 {
|
||||
t.Fatalf("before the range: %v", none)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1228/AC6 (cc part): cc <my address> answers only the mail with that address in cc, not the one with it only in
|
||||
// to (words are 1229's; this covers the cc field alone).
|
||||
func TestSearchFiltersByCcNotTo(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Cc: "jschoubben@novox.be", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, got, "<renov1@mesh>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1228 (review round 1, finding 4): a domain filter of "%" or "_" is never a LIKE wildcard; it matches nobody,
|
||||
// the same in both stores (a Postgres-only bug matched every mail in Postgres, since '%@' || '%' is the
|
||||
// wildcard '%@%', while memStore's strings.HasSuffix never treats '%' specially).
|
||||
func TestSearchFiltersByDomainNeverTreatsPercentAsAWildcard(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, From: []string{"%"}, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("from:[%%] matched %d mail(s): %v", len(got), got)
|
||||
}
|
||||
toUnderscore, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, To: "_", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(toUnderscore) != 0 {
|
||||
t.Fatalf("to:_ matched %d mail(s): %v", len(toUnderscore), toUnderscore)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1229/AC6 (words part): cc <my address> and the word "renovatie" in subject or body, combined with the cc filter
|
||||
// from 1228: the mail with it only in to is still excluded.
|
||||
func TestSearchFiltersByCcAndSubjectOrBodyWord(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Cc: "jschoubben@novox.be", Words: []string{"renovatie"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, got, "<renov1@mesh>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1229/AC5: from a domain, has-attachment, an attachment name pattern and a word in subject or body all combine.
|
||||
// The word ("factuur") lives only in the subject of the matching mail (review round 1 finding 3), so this
|
||||
// genuinely exercises Words's "or body", not merely a body match.
|
||||
func TestSearchFiltersByDomainAttachmentAndWord(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, From: []string{"google.com"}, HasAttachment: true,
|
||||
AttachmentNamePattern: "*.pdf", Words: []string{"factuur"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// carol@google.com's security-alert mail (uid 4) is from the same domain but has neither an attachment nor
|
||||
// the word, proving every one of the four conditions is enforced, not just one.
|
||||
sameSet(t, got, "<factuur1@mesh>")
|
||||
})
|
||||
}
|
||||
|
||||
// Review round 1, finding 3: SubjectWords and BodyWords together mean "and" (a word in each field, possibly two
|
||||
// different fields at once) — never a stand-in for "the word in subject or body". factuur1@mesh's "factuur" is
|
||||
// only in its subject, so the two together find nothing; Words finds it.
|
||||
func TestWordsIsNotSubjectWordsAndBodyWordsTogether(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
asAnd, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, SubjectWords: []string{"factuur"},
|
||||
BodyWords: []string{"factuur"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(asAnd) != 0 {
|
||||
t.Fatalf("subject-words and body-words together: %v", asAnd)
|
||||
}
|
||||
asWords, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Words: []string{"factuur"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, asWords, "<factuur1@mesh>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1229/AC9: on-or-after a date and an attachment type or name pattern (either suffices) answers a calendar
|
||||
// invite. cal2@gmail matches by its type alone (its name does not match the glob) and cal3@gmail by its name
|
||||
// alone (its type is not text/calendar) — review round 1, "also in this pass": proving each half of the "or"
|
||||
// independently, not only cal1@mesh, which matches both at once and so would pass even if one half were broken.
|
||||
func TestSearchFiltersByDateAndAttachmentTypeOrName(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, OnOrAfter: time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC),
|
||||
AttachmentType: "text/calendar", AttachmentNamePattern: "*.ics", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, got, "<cal1@mesh>", "<cal2@gmail>", "<cal3@gmail>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1229/AC12: a word with digits and a dash is matched whole, not as its separate parts. It lives only in the
|
||||
// subject of the matching mail (review round 1 finding 3), so Words's "subject or body" is genuinely exercised.
|
||||
func TestSearchFiltersBySubjectOrBodyWordWhole(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, Words: []string{"4711-882"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, got, "<order1@mesh>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1229/AC13: from a domain and the subject words (default "all": every word of the list), across every granted
|
||||
// account, each answer naming its own account.
|
||||
func TestSearchFiltersByDomainAndSubjectWordsAcrossAccounts(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, From: []string{"google.com"},
|
||||
SubjectWords: []string{"security", "alert"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, got, "<sec1@mesh>", "<gsec1@gmail>")
|
||||
accounts := map[string]bool{}
|
||||
for _, m := range got {
|
||||
accounts[m.Account] = true
|
||||
}
|
||||
if !accounts["mesh"] || !accounts["gmail"] {
|
||||
t.Fatalf("each answer should name its own account: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1229/AC15: a word list marked "any" (schade or verzekering) combined with has-attachment; the word list's "any"
|
||||
// mode matches a mail holding only one of the words, not both. polis1@mesh's "verzekering" lives only in its
|
||||
// subject (review round 1 finding 3), so Words's "subject or body" is genuinely exercised.
|
||||
func TestSearchFiltersByAnyWordsAndHasAttachment(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, Words: []string{"schade", "verzekering"},
|
||||
WordsAny: true, HasAttachment: true, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, got, "<gschade1@gmail>", "<polis1@mesh>")
|
||||
|
||||
// Without "any" (the default, "all"), only the mail holding both words together matches: polis1@mesh has
|
||||
// "verzekering" alone, so it drops out.
|
||||
all, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, Words: []string{"schade", "verzekering"},
|
||||
HasAttachment: true, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, all, "<gschade1@gmail>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1229 (unread/read, from 1228/AC2): the flag filter, read or unread, independent of the other filters.
|
||||
func TestSearchFiltersByUnreadFlag(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
unread := true
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "INBOX", Unread: &unread, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, got, "<invoice1@mesh>", "<reply1@mesh>", "<factuur1@mesh>", "<order1@mesh>", "<order2@mesh>", "<polis1@mesh>")
|
||||
|
||||
read := false
|
||||
gotRead, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "INBOX", Unread: &read, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, gotRead, "<renov1@mesh>", "<renov2@mesh>", "<sec1@mesh>", "<cal1@mesh>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1228/AC2 combined with 1229's unread, in one query (review round 1, "also in this pass"): on-or-after today
|
||||
// and unread together exclude cal1@mesh, dated today but read (\Seen), which either filter alone would include.
|
||||
func TestSearchFiltersByOnOrAfterAndUnreadTogether(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
unread := true
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "INBOX",
|
||||
OnOrAfter: time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC), Unread: &unread, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, got, "<invoice1@mesh>", "<reply1@mesh>", "<factuur1@mesh>", "<order1@mesh>", "<order2@mesh>", "<polis1@mesh>")
|
||||
})
|
||||
}
|
||||
|
||||
// sameOrder asserts got's message-ids equal want, in that exact order (unlike sameSet, which ignores order).
|
||||
func sameOrder(t *testing.T, got []Message, want ...string) {
|
||||
t.Helper()
|
||||
g := make([]string, 0, len(got))
|
||||
for _, m := range got {
|
||||
g = append(g, m.MessageID)
|
||||
}
|
||||
if len(g) != len(want) {
|
||||
t.Fatalf("got %v, want %v", g, want)
|
||||
}
|
||||
for i := range g {
|
||||
if g[i] != want[i] {
|
||||
t.Fatalf("got %v, want %v", g, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1230/AC7: a date range ("last month") ordered by size descending, limit bounding the answer.
|
||||
func TestSearchOrdersBySizeDescending(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, OnOrAfter: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC),
|
||||
Before: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), OrderBy: "size", OrderDesc: true, Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameOrder(t, got, "<renov1@mesh>", "<renov2@mesh>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1230/AC11: from several domains (OR'd) and on-or-after a date, order by date ascending, oldest first; limit
|
||||
// bounds the answer.
|
||||
func TestSearchFiltersBySeveralDomainsOrderedAscending(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, From: []string{"example.org", "google.com"},
|
||||
OnOrAfter: searchNow.AddDate(0, 0, -90), OrderBy: "date", OrderDesc: false, Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// junk1/junk3 (example.org, Junk) and sec1 (google.com, INBOX) prove both domains and every folder are
|
||||
// reached; the ascending order (oldest first) proves OrderDesc: false, not merely the absence of a filter.
|
||||
sameOrder(t, got, "<renov1@mesh>", "<renov2@mesh>", "<junk1@mesh>", "<junk3@mesh>", "<sec1@mesh>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1230 (the order-by mechanism beyond date and size, not tied to one AC): sender and subject each sort, and the
|
||||
// tie-break on (account, folder, uid) is stable when the chosen field is equal.
|
||||
func TestSearchOrdersBySenderAndSubject(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
bySender, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "Sent", OrderBy: "sender", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Both Sent messages share the same sender (jschoubben@novox.be): the tie-break (account, folder, uid)
|
||||
// orders them by uid ascending.
|
||||
sameOrder(t, bySender, "<sentreply1@mesh>", "<sent1@mesh>")
|
||||
|
||||
bySubject, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "Junk", OrderBy: "subject", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameOrder(t, bySubject, "<junk3@mesh>", "<junk1@mesh>", "<junk2@mesh>") // "a gmail..." < "special offer..." < "you won..."
|
||||
})
|
||||
}
|
||||
|
||||
// 1230/AC10: group by sender, folder INBOX, by count descending (address ascending to break ties); limit bounds
|
||||
// the number of lines, not the number of messages.
|
||||
func TestGroupBySenderOrdersByCountDescending(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.GroupBySender(ctx, Query{Accounts: []string{"mesh"}, Folder: "INBOX", Limit: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
want := []struct {
|
||||
address string
|
||||
count int
|
||||
}{{"alice@example.org", 2}, {"bob@example.org", 2}, {"ops@example.org", 2}}
|
||||
for i, w := range want {
|
||||
if got[i].Address != w.address || got[i].Count != w.count {
|
||||
t.Fatalf("line %d: got %+v, want %+v", i, got[i], w)
|
||||
}
|
||||
}
|
||||
// bob's two INBOX mails are invoice1 (09:00) and reply1 (12:30): the newest is reply1's.
|
||||
for _, g := range got {
|
||||
if g.Address == "bob@example.org" && !g.Newest.Equal(time.Date(2026, 10, 19, 12, 30, 0, 0, time.UTC)) {
|
||||
t.Fatalf("bob's newest: %v", g.Newest)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1230 (review round 1 finding 6): the same address, written differently across two mails, is one sender, not
|
||||
// two — grouped case-insensitively, in both stores. Its displayed address and name are the newest mail's own
|
||||
// (not max()'s lexicographically largest, and not whichever memStore's map iteration visited first), and
|
||||
// order-by sender compares case-insensitively too.
|
||||
func TestGroupBySenderAndOrderBySenderAreCaseInsensitive(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
older := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
|
||||
newer := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
|
||||
for _, m := range []Message{
|
||||
{Account: "mixedcase", Folder: "INBOX", UID: 1, MessageID: "<mc1>", Subject: "first", Date: older,
|
||||
From: []Person{{Name: "Bob", Address: "Bob@Example.ORG"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
{Account: "mixedcase", Folder: "INBOX", UID: 2, MessageID: "<mc2>", Subject: "second", Date: newer,
|
||||
From: []Person{{Name: "Bobby", Address: "bob@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
{Account: "mixedcase", Folder: "INBOX", UID: 3, MessageID: "<mc3>", Subject: "third", Date: older.Add(time.Hour),
|
||||
From: []Person{{Name: "Alice", Address: "alice@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
} {
|
||||
if err := st.Add(ctx, m, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
groups, err := st.GroupBySender(ctx, Query{Accounts: []string{"mixedcase"}, Folder: "INBOX", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("Bob@Example.ORG and bob@example.org were not merged into one sender: %+v", groups)
|
||||
}
|
||||
var bob *SenderGroup
|
||||
for i := range groups {
|
||||
if strings.EqualFold(groups[i].Address, "bob@example.org") {
|
||||
bob = &groups[i]
|
||||
}
|
||||
}
|
||||
if bob == nil || bob.Count != 2 || bob.Address != "bob@example.org" || bob.Name != "Bobby" || !bob.Newest.Equal(newer) {
|
||||
t.Fatalf("bob's merged line: %+v", bob)
|
||||
}
|
||||
|
||||
bySender, err := st.Search(ctx, Query{Accounts: []string{"mixedcase"}, Folder: "INBOX", OrderBy: "sender", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameOrder(t, bySender, "<mc3>", "<mc1>", "<mc2>") // alice@... before Bob@.../bob@... (case-insensitive)
|
||||
})
|
||||
}
|
||||
|
||||
// 1231/AC8: in-reply-to a mail I sent, combined with from (its recipient), answers the replies to it; alone, it
|
||||
// answers every reply in the thread's direct answer set.
|
||||
func TestSearchFiltersByInReplyTo(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
withFrom, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, InReplyTo: "<sent1@mesh>",
|
||||
From: []string{"bob@example.org"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, withFrom, "<reply1@mesh>")
|
||||
|
||||
alone, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, InReplyTo: "<sent1@mesh>", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, alone, "<reply1@mesh>")
|
||||
|
||||
// <sentreply1@mesh> replies to <invoice1@mesh>, a different mail, so it never answers here.
|
||||
other, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, InReplyTo: "<invoice1@mesh>", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, other, "<sentreply1@mesh>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1231/AC14: folder Junk and from-someone-I-wrote-to answers only the junk mail whose sender is a recipient of a
|
||||
// mail in the same account's Sent folder. spam@unknown.com, never written to, is excluded; so is junk3@mesh,
|
||||
// from someone@example.org, whom only gmail wrote to (gmail/[Gmail]/Sent Mail) and never mesh (review round 1
|
||||
// finding 2: the subquery must stay scoped to the same account, not leak another account's Sent folder).
|
||||
func TestSearchFiltersByFromSomeoneIWroteTo(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "Junk", FromSomeoneIWroteTo: true, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, got, "<junk1@mesh>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1231/AC14 (review round 1 finding 1): gmail's own Sent folder is named "[Gmail]/Sent Mail", not "Sent"; the
|
||||
// filter must still recognise it as a Sent folder.
|
||||
func TestSearchFiltersByFromSomeoneIWroteToRecognisesGmailsSentFolderName(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"gmail"}, Folder: "Junk", FromSomeoneIWroteTo: true, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, got, "<gjunk1@gmail>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1248/AC1: gdup1@gmail lives in both INBOX and [Gmail]/All Mail with the same message-id. Searched with no
|
||||
// folder, it is answered once, from INBOX, never from the archive.
|
||||
func TestSearchWithNoFolderAnswersAGmailMailOnceFromInbox(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"gmail"}, Words: []string{"digest"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found []Message
|
||||
for _, m := range got {
|
||||
if m.MessageID == "<gdup1@gmail>" {
|
||||
found = append(found, m)
|
||||
}
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("gdup1@gmail answered %d time(s): %+v", len(found), found)
|
||||
}
|
||||
if found[0].Folder != "INBOX" {
|
||||
t.Fatalf("gdup1@gmail answered from %q, want INBOX", found[0].Folder)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1248/AC2: garchived1@gmail lives only in [Gmail]/All Mail (archived out of the inbox). Searched with no
|
||||
// folder, it is still answered once, from the archive, since no other copy exists.
|
||||
func TestSearchWithNoFolderAnswersAnArchivedOnlyGmailMailFromAllMail(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"gmail"}, Words: []string{"newsletter"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("garchived1@gmail answered %d time(s): %+v", len(got), got)
|
||||
}
|
||||
if got[0].MessageID != "<garchived1@gmail>" || got[0].Folder != "[Gmail]/All Mail" {
|
||||
t.Fatalf("got %+v, want <garchived1@gmail> from [Gmail]/All Mail", got[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1248/AC3: naming the folder [Gmail]/All Mail answers every mail of that folder, never merging it with the
|
||||
// INBOX copy of the same message-id: gdup1's All Mail copy (uid 100) and garchived1 (uid 101) both come back.
|
||||
func TestSearchByAllMailFolderNamedAnswersEveryMailOfItUnmerged(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"gmail"}, Folder: "[Gmail]/All Mail", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sameSet(t, got, "<gdup1@gmail>", "<garchived1@gmail>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1248/AC4: group-by-sender with no folder named counts dash@example.org once, not twice, even though gdup1@gmail
|
||||
// (its only mail) sits in both INBOX and [Gmail]/All Mail.
|
||||
func TestGroupBySenderWithNoFolderCountsAGmailMailOnce(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
addFixtures(t, ctx, st)
|
||||
got, err := st.GroupBySender(ctx, Query{Accounts: []string{"gmail"}, From: []string{"dash@example.org"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Count != 1 {
|
||||
t.Fatalf("got %+v, want one line with count 1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1248/AC5: two mails with an empty message-id are never merged with each other or with anything, even though a
|
||||
// search with no folder is otherwise asked to answer each message-id once.
|
||||
func TestSearchWithNoFolderNeverMergesEmptyMessageIDs(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
for _, m := range []Message{
|
||||
{Account: "noid", Folder: "INBOX", UID: 1, MessageID: "", Subject: "no id one", Date: at1248(9, 0),
|
||||
From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
{Account: "noid", Folder: "INBOX", UID: 2, MessageID: "", Subject: "no id two", Date: at1248(9, 5),
|
||||
From: []Person{{Address: "b@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
} {
|
||||
if err := st.Add(ctx, m, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"noid"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("two empty-message-id mails merged: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1248/AC6: the same message-id in two accounts (a mail sent from mesh to gmail, each account indexing its own
|
||||
// copy) is answered once per account: twice in all, never merged across accounts.
|
||||
func TestSearchWithNoFolderAnswersTheSameMessageIDOncePerAccount(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
for _, m := range []Message{
|
||||
{Account: "mesh", Folder: "Sent", UID: 50, MessageID: "<cross1@shared>", Subject: "cross-account",
|
||||
Date: at1248(9, 0), From: []Person{{Address: "jschoubben@novox.be"}}, To: []Person{{Address: "me@gmail.example"}},
|
||||
Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
{Account: "gmail", Folder: "INBOX", UID: 50, MessageID: "<cross1@shared>", Subject: "cross-account",
|
||||
Date: at1248(9, 1), From: []Person{{Address: "jschoubben@novox.be"}}, To: []Person{{Address: "me@gmail.example"}},
|
||||
Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
} {
|
||||
if err := st.Add(ctx, m, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var meshCount, gmailCount int
|
||||
for _, m := range got {
|
||||
if m.MessageID != "<cross1@shared>" {
|
||||
continue
|
||||
}
|
||||
if m.Account == "mesh" {
|
||||
meshCount++
|
||||
}
|
||||
if m.Account == "gmail" {
|
||||
gmailCount++
|
||||
}
|
||||
}
|
||||
if meshCount != 1 || gmailCount != 1 {
|
||||
t.Fatalf("mesh: %d, gmail: %d, want 1 each: %+v", meshCount, gmailCount, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// at1248 is a fixed time for 1248's own dedicated fixtures (AC5-AC7), separate from searchFixtures' at() helper.
|
||||
func at1248(h, m int) time.Time { return time.Date(2026, 10, 19, h, m, 0, 0, time.UTC) }
|
||||
|
||||
// 1248/AC7: limit 5 over mails that each sit in two folders (INBOX and [Gmail]/All Mail) answers 5 different
|
||||
// mails, in the asked (newest-first default) order — never fewer because duplicates were counted against the
|
||||
// limit, and never a duplicate taking one of the 5 slots.
|
||||
func TestSearchWithNoFolderAndLimitAnswersDistinctMailsInOrder(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
ids := []string{"<many1@dup>", "<many2@dup>", "<many3@dup>", "<many4@dup>", "<many5@dup>", "<many6@dup>"}
|
||||
for i, id := range ids {
|
||||
date := at1248(8, 0).Add(time.Duration(i) * time.Hour)
|
||||
for _, folder := range []string{"INBOX", "[Gmail]/All Mail"} {
|
||||
uid := uint32(200 + i)
|
||||
if folder == "[Gmail]/All Mail" {
|
||||
uid += 1000
|
||||
}
|
||||
m := Message{Account: "manyfolders", Folder: folder, UID: uid, MessageID: id, Subject: "dup " + id,
|
||||
Date: date, From: []Person{{Address: "dup@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}}
|
||||
if err := st.Add(ctx, m, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"manyfolders"}, Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Newest first (the default): many6, many5, many4, many3, many2 — many1 is excluded by the limit.
|
||||
sameOrder(t, got, "<many6@dup>", "<many5@dup>", "<many4@dup>", "<many3@dup>", "<many2@dup>")
|
||||
})
|
||||
}
|
||||
|
||||
// 1248/AC1 (review round 1, finding 1): the same message-id sits in two non-archive folders (INBOX and a label
|
||||
// folder, "INBOX/Telenet", as gmail keeps a label as a second folder) as well as the archive. Searched with no
|
||||
// folder, it is still answered once, and from INBOX — the fixed tie-break (non-archive before archive, then
|
||||
// folder ascending, then uid ascending) picks "INBOX" over "INBOX/Telenet" because it sorts first, never by
|
||||
// whichever copy a Go map's randomised iteration happened to meet first.
|
||||
func TestSearchWithNoFolderPicksTheSameCopyAsPostgresAmongTwoNonArchiveFolders(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, st Store) {
|
||||
ctx := context.Background()
|
||||
for _, m := range []Message{
|
||||
{Account: "labeled", Folder: "INBOX", UID: 1, MessageID: "<labeled1@dup>", Subject: "labeled",
|
||||
Date: at1248(9, 0), From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
{Account: "labeled", Folder: "INBOX/Telenet", UID: 1, MessageID: "<labeled1@dup>", Subject: "labeled",
|
||||
Date: at1248(9, 0), From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
{Account: "labeled", Folder: "[Gmail]/All Mail", UID: 1, MessageID: "<labeled1@dup>", Subject: "labeled",
|
||||
Date: at1248(9, 0), From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
} {
|
||||
if err := st.Add(ctx, m, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"labeled"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("labeled1@dup answered %d time(s): %+v", len(got), got)
|
||||
}
|
||||
if got[0].Folder != "INBOX" {
|
||||
t.Fatalf("got folder %q, want INBOX", got[0].Folder)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
// The tools never open a password file (novox/hq ADR 0436 §12, issue 1193): ReadSecret and SecretPath moved
|
||||
// into cmd/mesh-mail/internal/secret, imported only by keep (imap.go) and — once issue 1195 adds it — the
|
||||
// sender's own process (sender.go). Since issue 1194, Decide no longer reaches any actor that reads a secret
|
||||
// at all (it records approved and queues the send for the sender); the import list this test checks still
|
||||
// holds, now even more plainly. The build seat runs every test under one account, so no permission test can
|
||||
// show the split (context file, "Clause 12's proof"); this is the file-level proxy the lead chose instead: an
|
||||
// AST check over the tools' own files.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// secretPackage is the import path the tools' bundle must never carry.
|
||||
const secretPackage = "mesh-mail/cmd/mesh-mail/internal/secret"
|
||||
|
||||
// 1193/AC1: none of the tools' bundle files (main.go's serve path, tools.go, send.go, health.go) imports the
|
||||
// password-reading package; the failure names the offending file.
|
||||
func TestTheToolsBundleNeverImportsThePasswordPackage(t *testing.T) {
|
||||
for _, file := range []string{"main.go", "tools.go", "send.go", "health.go"} {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", file, err)
|
||||
}
|
||||
for _, imp := range f.Imports {
|
||||
path := strings.Trim(imp.Path.Value, `"`)
|
||||
if path == secretPackage {
|
||||
t.Errorf("%s imports %s, which only keep and the sender's actor may (novox/hq ADR 0436 §12)", file, secretPackage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1193/AC2: keep (imap.go) and the sender (sender.go, issue 1195) read the password through the moved
|
||||
// package; SecretGiven (stat only) stays in config.go, available to the tools, and never reads the password
|
||||
// itself. (sender.go's own account file is read through secret.Path too, though its own account is not a
|
||||
// password; the real per-message password read is issue 1196's.)
|
||||
func TestKeepAndTheSenderReadThePasswordThroughTheMovedPackage(t *testing.T) {
|
||||
for _, file := range []string{"imap.go", "sender.go"} {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", file, err)
|
||||
}
|
||||
found := false
|
||||
for _, imp := range f.Imports {
|
||||
if strings.Trim(imp.Path.Value, `"`) == secretPackage {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s does not import %s", file, secretPackage)
|
||||
}
|
||||
}
|
||||
// SecretGiven itself (config.go) never calls ReadFile, os.Open or the moved package: whether a password was
|
||||
// given is read from the file's presence and size alone (issue 733, kept by config_test.go's own check too).
|
||||
src, err := os.ReadFile("config.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(src)
|
||||
body = body[strings.Index(body, "func SecretGiven("):]
|
||||
body = body[:strings.Index(body, "\n}\n")]
|
||||
for _, bad := range []string{"ReadSecret", "ReadFile", "os.Open", "secret.Read"} {
|
||||
if strings.Contains(body, bad) {
|
||||
t.Fatalf("SecretGiven reads the password (%s):\n%s", bad, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1193/AC3: with the secrets directory absent, mail_send raises its ask, the warrant of an Approve is heard,
|
||||
// and mail_send_status reads it back — each succeeds, and no error mentions a secret file. Since issue 1194
|
||||
// the tools never read a secret on this path at all (they queue the send for the sender), so this also proves
|
||||
// the stronger property the tools' bundle never touches b.Env.Secrets.
|
||||
func TestTheToolPathSucceedsWithTheSecretsDirectoryAbsent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missing := filepath.Join(dir, "never-created")
|
||||
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: missing}
|
||||
writeFile(t, env.Accounts, `{"accounts": {"mesh": {"kind": "imap", "address": "jschoubben@novox.be",
|
||||
"incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"},
|
||||
"outgoing": {"host": "h", "port": 465, "security": "tls"}}}}`)
|
||||
writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`)
|
||||
router, sends, queue := newSendRouter(), newMemSends(), newMemQueue()
|
||||
b := &Bundle{Env: env, Now: func() time.Time { return judgedAt }, Asks: router, Sends: sends, Queue: queue.publish}
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
||||
if isErr {
|
||||
t.Fatalf("mail_send: %s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
|
||||
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
|
||||
router.close(w)
|
||||
d, err := Decide(sends, b.queuePublish(), w, judgedAt.Add(2*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("the warrant was not heard cleanly: %v", err)
|
||||
}
|
||||
if d.Outcome != "queued" {
|
||||
t.Fatalf("the decision: %+v; want queued (the tools never read the secrets directory)", d)
|
||||
}
|
||||
if strings.Contains(d.Outcome, missing) || strings.Contains(strings.ToLower(d.Outcome), "secret") {
|
||||
t.Fatalf("the decision names a secret file: %q", d.Outcome)
|
||||
}
|
||||
|
||||
text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
|
||||
if isErr {
|
||||
t.Fatalf("mail_send_status: %s", text)
|
||||
}
|
||||
if strings.Contains(text, missing) || strings.Contains(strings.ToLower(text), "secret") {
|
||||
t.Fatalf("mail_send_status names a secret file: %s", text)
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,20 @@ type SendDecision struct {
|
||||
Outcome string `json:"outcome"`
|
||||
Warrant string `json:"warrant,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Attempt is which handover to the server this is, 1 for the first: the sender's own claim from queued
|
||||
// or retrying to sending counts it (novox/hq ADR 0436 clauses 9, 10, issue 1196).
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
// NextAttempt is when a "retrying" send is next claimed, the bus's own redelivery time (novox/hq ADR
|
||||
// 0436 clause 10, issue 1198): read by mail_health to say a send overdue (issue 1199).
|
||||
NextAttempt time.Time `json:"next-attempt,omitempty"`
|
||||
// Tell is the sender's own tell to the operator for a failed, refused or not-proven send, kept to once:
|
||||
// "" (not owed, or not yet attempted), "telling" (claimed, the channel not yet confirmed it took the
|
||||
// message) or "told" (novox/hq ADR 0436 clause 11, issue 1199).
|
||||
Tell string `json:"tell,omitempty"`
|
||||
// WaitsTell is the tools' own tell that an overdue send's sender has not taken it, kept to once the
|
||||
// same way as Tell, on its own field: the two are never the same tell (novox/hq ADR 0436 clause 11,
|
||||
// issue 1199).
|
||||
WaitsTell string `json:"waits-tell,omitempty"`
|
||||
}
|
||||
|
||||
// bodyDigest is the digest an act binds in place of the body itself: never the body, which the act's digest is
|
||||
@@ -554,18 +568,31 @@ func looksLikeHostName(w string) bool {
|
||||
|
||||
// ---- deciding ---------------------------------------------------------------------------------------------------
|
||||
|
||||
// AccountLookup resolves an account by name, as the settings currently hold it (not as it stood when it was asked:
|
||||
// the account's server may have changed since, and its password is read fresh), or found false.
|
||||
type AccountLookup func(name string) (Account, bool)
|
||||
// MailSendApproved is the event mesh-mail's tools publish on the work queue mail-sends once the operator's
|
||||
// Approve is verified and the send recorded approved (novox/hq ADR 0436 clause 8).
|
||||
const MailSendApproved = "mail.send.approved"
|
||||
|
||||
// SendActor performs the one send a chosen option authorises, resolving the account's own secret itself. It is
|
||||
// called at most once per request (Decide claims the request first).
|
||||
type SendActor func(a Account, to []string, subject, body string, now time.Time) error
|
||||
// QueuedSend is the body mail.send.approved carries: never the recipients, the subject or the body, which stay
|
||||
// in the send's own record in `sends` (novox/hq ADR 0436 clause 8).
|
||||
type QueuedSend struct {
|
||||
Send string `json:"send"`
|
||||
Account string `json:"account"`
|
||||
Ask string `json:"ask"`
|
||||
}
|
||||
|
||||
// settleSend writes a decision onto a kept send (claiming it, or ending it outright) and, once the write lands,
|
||||
// drops it from the undecided index: an index entry is never left behind once the record it points to carries a
|
||||
// decision (novox/hq issue 811). On a race lost to another writer, it reads back whatever that writer decided.
|
||||
func settleSend(store SendStore, req SendRequest, rev uint64, d SendDecision) (SendDecision, uint64, error) {
|
||||
// QueuePublish publishes one message on the module's own work-queue stream, the message id chosen (here, the
|
||||
// send id) so a repeated publish inside the stream's duplicate window is kept to one (novox/hq ADR 0436
|
||||
// clauses 6, 8); nothing here reads or holds a password. Real is sdkQueuePublish; a test gives its own.
|
||||
type QueuePublish func(id string, body any) error
|
||||
|
||||
func sdkQueuePublish(id string, body any) error {
|
||||
return stdio.Emit(MailSendApproved, body, stdio.EmitOptions{MessageID: id})
|
||||
}
|
||||
|
||||
// updateDecision writes a decision onto a kept send by compare-and-set, without touching the undecided index:
|
||||
// the index is settleSend's to drop, once the decision is one the sweep no longer needs to look at again
|
||||
// (novox/hq issue 811). On a race lost to another writer, it reads back whatever that writer decided.
|
||||
func updateDecision(store SendStore, req SendRequest, rev uint64, d SendDecision) (SendDecision, uint64, error) {
|
||||
next := req
|
||||
next.Decided = &d
|
||||
newRev, err := store.Update(req.ID, next, rev)
|
||||
@@ -577,16 +604,30 @@ func settleSend(store SendStore, req SendRequest, rev uint64, d SendDecision) (S
|
||||
}
|
||||
return d, 0, err
|
||||
}
|
||||
_ = removeUndecided(store, req.ID) // best-effort (see removeUndecided)
|
||||
return d, newRev, nil
|
||||
}
|
||||
|
||||
// settleSend writes a decision onto a kept send (claiming it, or ending it outright) and, once the write lands,
|
||||
// drops it from the undecided index: an index entry is never left behind once the record it points to carries a
|
||||
// decision the sweep no longer needs to act on (novox/hq issue 811). On a race lost to another writer, it reads
|
||||
// back whatever that writer decided.
|
||||
func settleSend(store SendStore, req SendRequest, rev uint64, d SendDecision) (SendDecision, uint64, error) {
|
||||
final, newRev, err := updateDecision(store, req, rev, d)
|
||||
if err != nil {
|
||||
return final, 0, err
|
||||
}
|
||||
if newRev != 0 {
|
||||
_ = removeUndecided(store, req.ID) // best-effort (see removeUndecided)
|
||||
}
|
||||
return final, newRev, nil
|
||||
}
|
||||
|
||||
// Decide holds one closed ask's warrant to the request it answers (asks.Warrant.For, asks.Option.Performs), and —
|
||||
// on Approve, exactly once — performs the send. A request already decided is never decided again: decide answers
|
||||
// its kept decision at once, and two warrants for the same request (a redelivery, or two readers racing) have only
|
||||
// the first claim the request by a compare-and-set write; the second finds it already claimed or decided and sends
|
||||
// nothing.
|
||||
func Decide(store SendStore, lookup AccountLookup, send SendActor, w asks.Warrant, now time.Time) (SendDecision, error) {
|
||||
// on Approve, exactly once — records the send approved and queues it for the sender (novox/hq ADR 0436 clause 8);
|
||||
// it never sends. A request already decided is never decided again: decide answers its kept decision at once, and
|
||||
// two warrants for the same request (a redelivery, or two readers racing) have only the first claim the request
|
||||
// by a compare-and-set write; the second finds it already claimed or decided and queues nothing twice.
|
||||
func Decide(store SendStore, publish QueuePublish, w asks.Warrant, now time.Time) (SendDecision, error) {
|
||||
req, rev, ok, err := getSend(store, w.Ask)
|
||||
if err != nil {
|
||||
return SendDecision{}, err
|
||||
@@ -623,62 +664,678 @@ func Decide(store SendStore, lookup AccountLookup, send SendActor, w asks.Warran
|
||||
case OptionDecline:
|
||||
return settle(SendDecision{At: now, Outcome: "declined", Warrant: w.Says()})
|
||||
case OptionSend:
|
||||
return decideSend(store, lookup, send, req, rev, w, now)
|
||||
return decideSend(store, publish, req, rev, w, now)
|
||||
default:
|
||||
return settle(SendDecision{At: now, Outcome: "refused: the option " + option.ID + " is not one mail_send offers"})
|
||||
}
|
||||
}
|
||||
|
||||
// decideSend claims the request by a compare-and-set write before it sends, so a redelivered warrant finds it
|
||||
// already claimed (or decided) and acts on nothing: a person's Approve is performed once, never retried on a
|
||||
// transient failure (the failure is kept in the decision, not hidden).
|
||||
func decideSend(store SendStore, lookup AccountLookup, send SendActor, req SendRequest, rev uint64, w asks.Warrant, now time.Time) (SendDecision, error) {
|
||||
claimed := req
|
||||
claim := SendDecision{At: now, Outcome: "sending", Warrant: w.Says()}
|
||||
claimedDecision, newRev, err := settleSend(store, req, rev, claim)
|
||||
// decideSend records the request queued by a compare-and-set write before it publishes, so a redelivered
|
||||
// warrant finds it already claimed (or decided) and queues nothing twice: a person's Approve is queued once
|
||||
// (novox/hq ADR 0436 clause 8, as decided to replace the two-state approved/queued design — review round 1,
|
||||
// replacing finding 2's work-around with a decision changing the clause itself). There is no "approved"
|
||||
// state in a send's life at all: the record moves straight from undecided to queued; the publish that
|
||||
// follows is best-effort, and a publish that fails (or the tools stopping before it ever runs) leaves the
|
||||
// record "queued" with its own time, which the sweep finds and republishes once a short grace has passed
|
||||
// (Sweep, below) — the send id as the message id, and the stream's duplicate window, keep it one message
|
||||
// either way, and the sender's own claim (clause 9) keeps it one send. It never sends: the sender (clause 7)
|
||||
// takes the queued message, verifies the warrant again itself (clause 9) and performs the one handover to
|
||||
// the server (clause 10).
|
||||
func decideSend(store SendStore, publish QueuePublish, req SendRequest, rev uint64, w asks.Warrant, now time.Time) (SendDecision, error) {
|
||||
// Indexed before the record carries "queued" (novox/hq review round 1 finding 3): a crash between this
|
||||
// and the write below must still leave it findable by mail_health's overdue check and the sweep's own
|
||||
// stale-queued retry once "queued" does land.
|
||||
indexErr := addSenderPending(store, req.ID)
|
||||
queued := SendDecision{At: now, Outcome: "queued", Warrant: w.Says()}
|
||||
claimed, newRev, err := settleSend(store, req, rev, queued)
|
||||
if err != nil {
|
||||
return SendDecision{}, err
|
||||
return SendDecision{}, errOr(indexErr, err)
|
||||
}
|
||||
if newRev == 0 {
|
||||
// Lost the claim race: another writer already decided this send.
|
||||
return claimedDecision, nil
|
||||
return claimed, indexErr
|
||||
}
|
||||
// Best-effort: its own failure is never returned to the caller as this call's own error — the record is
|
||||
// already durably "queued" either way, and the sweep retries a publish that did not land.
|
||||
_ = publish(req.ID, QueuedSend{Send: req.ID, Account: req.Account, Ask: req.ID})
|
||||
return claimed, indexErr
|
||||
}
|
||||
|
||||
// ---- the sender's own claim (novox/hq ADR 0436 clauses 9, 10, issue 1196) --------------------------------------
|
||||
//
|
||||
// The sender never trusts the queue's message: it reads the router's record of mesh-mail's own ask under its
|
||||
// own account, and mirrors the tools' own check (Decide, above) exactly — w.For, then option.Performs on the
|
||||
// request's own Act — before it claims the send by compare-and-set and, only then, connects to anything.
|
||||
|
||||
// RouterRecord is how the sender reads the router's record of one ask: a function so a test gives a fake, and
|
||||
// the real one (sender.go) wraps a process.Conn's Ask(bucket, id) over the operator channel's records.
|
||||
type RouterRecord func(id string) (*stdio.StateEntry, error)
|
||||
|
||||
// claimOutcome is what became of one claim, so the caller (the message handler) knows the message's fate.
|
||||
type claimOutcome int
|
||||
|
||||
const (
|
||||
// claimToSend: claimed "sending" by this call; the caller may now connect and send.
|
||||
claimToSend claimOutcome = iota
|
||||
// claimRefused: recorded refused for a reason this function names; the caller terminates the message.
|
||||
claimRefused
|
||||
// claimNotProven: the send was already "sending" (a previous delivery may have handed it to the server);
|
||||
// recorded not proven; the caller terminates the message, never retrying it automatically.
|
||||
claimNotProven
|
||||
// claimLostRace: the compare-and-set to "sending" lost to another writer (another delivery of the same
|
||||
// message, racing this one); nothing is recorded here and nothing sent; the caller leaves the message to
|
||||
// the bus entirely — no Ack, no Nak, no Term.
|
||||
claimLostRace
|
||||
// claimAlreadyFinished: a redelivery of a message whose send already finished (sent, failed, refused
|
||||
// or not proven) — the sender's own ack was lost, or two deliveries raced and the other one already
|
||||
// finished it. Nothing is recorded, emitted or told again; the caller only terminates the message
|
||||
// (novox/hq ADR 0436 clause 10, operator-approved change).
|
||||
claimAlreadyFinished
|
||||
)
|
||||
|
||||
// isFinishedOutcome says whether a decided send's outcome is already over, in the sense clause 10 means it:
|
||||
// sent, failed (any reason), refused (any reason, including the sender's own clause-9 refusal), or not
|
||||
// proven. A redelivery that finds one of these is a repeat, never a new refusal (novox/hq review, the
|
||||
// operator-approved change to clause 10).
|
||||
func isFinishedOutcome(outcome string) bool {
|
||||
return outcome == "sent" || outcome == "not proven" ||
|
||||
strings.HasPrefix(outcome, "failed: ") || strings.HasPrefix(outcome, "refused: ")
|
||||
}
|
||||
|
||||
// claimQueuedSend verifies the operator's warrant for id (mesh-mail's own ask) and claims the send by
|
||||
// compare-and-set from queued or retrying to sending, with the attempt and time, before any connection
|
||||
// (novox/hq ADR 0436 clauses 9, 10). It is never trusted from the queue's message alone: the ask is read
|
||||
// fresh from the router under the sender's own account, closed by the operator's Approve at level approve
|
||||
// within its expiry (Warrant.For), and its act's digest (the account, recipients, subject and body's digest)
|
||||
// is the option's binds (Option.Performs) — exactly the tools' own check (Decide, above) mirrored here,
|
||||
// because the tool runner's account, which every agent session on this machine also reaches the bus as, may
|
||||
// publish on the queue and write `sends` (this is the boundary clause 9 describes).
|
||||
func claimQueuedSend(store SendStore, record RouterRecord, emit Emit, id string, now time.Time) (SendRequest, SendDecision, uint64, claimOutcome, error) {
|
||||
req, rev, ok, err := getSend(store, id)
|
||||
if err != nil {
|
||||
return SendRequest{}, SendDecision{}, 0, claimLostRace, err
|
||||
}
|
||||
if !ok {
|
||||
return SendRequest{}, SendDecision{}, 0, claimLostRace, fmt.Errorf("no send %q is kept", id)
|
||||
}
|
||||
if req.Decided == nil {
|
||||
// Nothing was ever decided at all (never queued): no tell owed (clause 11 ties the tell to a
|
||||
// *failed approved* send), and nothing emitted either — clause 11's two events are for a send the
|
||||
// operator did approve.
|
||||
d, _, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "refused: the send is not yet approved"})
|
||||
return req, d, 0, claimRefused, err
|
||||
}
|
||||
// Every other refusal below follows a real Approve (the send reached at least queued): clause 9's own
|
||||
// refusal owes the operator a tell and the same mail.send.failed event as clause 10's refusal does
|
||||
// (novox/hq ADR 0436 clause 11, issue 1199; review round 1 finding 4).
|
||||
refuse := func(reason string) (SendRequest, SendDecision, uint64, claimOutcome, error) {
|
||||
// Indexed for a tell BEFORE the record carries "refused" (novox/hq review round 1 finding 3): a
|
||||
// crash between this and the write below must still leave it findable once refused does land;
|
||||
// tellSend itself tolerates finding it still sending, queued or retrying in the meantime.
|
||||
indexErr := addTellPending(store, req.ID)
|
||||
d, newRev, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "refused: " + reason})
|
||||
if newRev == 0 {
|
||||
// The write did not land (another writer's decision already stands, or it failed outright):
|
||||
// nothing of this call's own making to emit (novox/hq review round 2, issue 1198).
|
||||
return req, d, 0, claimRefused, errOr(indexErr, err)
|
||||
}
|
||||
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
|
||||
// Only once refused is durably recorded is the sender-pending index's entry dropped.
|
||||
if rmErr := removeSenderPending(store, req.ID); rmErr != nil {
|
||||
indexErr = errOr(indexErr, fmt.Errorf("dropping %s from the sender-pending index: %w", req.ID, rmErr))
|
||||
}
|
||||
return req, d, 0, claimRefused, indexErr
|
||||
}
|
||||
claimed.Decided = &claim
|
||||
acc, found := lookup(req.Account)
|
||||
var final SendDecision
|
||||
switch {
|
||||
case !found:
|
||||
final = SendDecision{At: now, Outcome: "refused: the account no longer exists", Warrant: w.Says()}
|
||||
default:
|
||||
if sendErr := send(acc, req.To, req.Subject, req.Body, now); sendErr != nil {
|
||||
final = SendDecision{At: now, Outcome: "failed: " + sendErr.Error(), Warrant: w.Says(), Error: sendErr.Error()}
|
||||
} else {
|
||||
final = SendDecision{At: now, Outcome: "sent", Warrant: w.Says()}
|
||||
case req.Decided.Outcome == "sending":
|
||||
// A previous delivery may have handed it to the server already; never sent twice, and never
|
||||
// retried automatically either way (ADR 0436 clause 10). Same ordering as refuse() above.
|
||||
indexErr := addTellPending(store, req.ID)
|
||||
d, newRev, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "not proven", Attempt: req.Decided.Attempt})
|
||||
if newRev == 0 {
|
||||
// The write did not land: nothing of this call's own making to emit (novox/hq review round 2,
|
||||
// issue 1198).
|
||||
return req, d, 0, claimNotProven, errOr(indexErr, err)
|
||||
}
|
||||
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
|
||||
if rmErr := removeSenderPending(store, req.ID); rmErr != nil {
|
||||
indexErr = errOr(indexErr, fmt.Errorf("dropping %s from the sender-pending index: %w", req.ID, rmErr))
|
||||
}
|
||||
return req, d, 0, claimNotProven, indexErr
|
||||
case isFinishedOutcome(req.Decided.Outcome):
|
||||
// A redelivery of a message whose send already finished — sent, failed, refused by the sender's
|
||||
// own claim, or not proven: the sender's own ack was lost (a bus reconnect) and the work-queue gave
|
||||
// it up after AckWait, or two deliveries raced and the other one already finished it. The operator
|
||||
// was already told (or sent) once; telling, emitting or recording anything again here would be a
|
||||
// second word about a send that is already over. Just a repeat: terminated, nothing else touched
|
||||
// (novox/hq ADR 0436 clause 10, operator-approved change).
|
||||
return req, *req.Decided, 0, claimAlreadyFinished, nil
|
||||
case req.Decided.Outcome != "queued" && req.Decided.Outcome != "retrying":
|
||||
return refuse(fmt.Sprintf("the send is %q, neither queued nor retrying", req.Decided.Outcome))
|
||||
}
|
||||
entry, err := record(id)
|
||||
if err != nil {
|
||||
return SendRequest{}, SendDecision{}, 0, claimLostRace, err
|
||||
}
|
||||
if entry == nil {
|
||||
return refuse("the router keeps no record of this ask")
|
||||
}
|
||||
var rec routerRecord
|
||||
if json.Unmarshal(entry.Value, &rec) != nil {
|
||||
return refuse("the router's record of it cannot be read")
|
||||
}
|
||||
if rec.State == "" || rec.State == "open" || rec.Warrant == nil {
|
||||
return refuse("the ask is not closed by the operator's Approve")
|
||||
}
|
||||
w := *rec.Warrant
|
||||
if w.Asker != Module {
|
||||
return refuse(fmt.Sprintf("the warrant is for %s's ask, not %s's", w.Asker, Module))
|
||||
}
|
||||
ask := req.Ask()
|
||||
option, err := w.For(Module, ask)
|
||||
if err == nil {
|
||||
err = option.Performs(req.Act(option.ID))
|
||||
}
|
||||
if err != nil {
|
||||
return refuse(err.Error())
|
||||
}
|
||||
if option.ID != OptionSend {
|
||||
return refuse("the option " + option.ID + " does not authorise a send")
|
||||
}
|
||||
claim := SendDecision{At: now, Outcome: "sending", Warrant: w.Says(), Attempt: req.Decided.Attempt + 1}
|
||||
claimed, newRev, err := updateDecision(store, req, rev, claim)
|
||||
if err != nil {
|
||||
return SendRequest{}, SendDecision{}, 0, claimLostRace, err
|
||||
}
|
||||
if newRev == 0 {
|
||||
// Lost the claim race: another delivery of the same message is already handling it.
|
||||
return req, claimed, 0, claimLostRace, nil
|
||||
}
|
||||
return req, claimed, newRev, claimToSend, nil
|
||||
}
|
||||
|
||||
// ---- the send's outcome, retry and emit (novox/hq ADR 0436 clauses 10, 11, issue 1198) --------------------------
|
||||
|
||||
// MailSendSent and MailSendFailed are the two events the sender emits, never anything else (novox/hq ADR
|
||||
// 0436 clause 11): the send id, the account and the outcome in words — never the recipients, the subject or
|
||||
// the body.
|
||||
const (
|
||||
MailSendSent = "mail.send.sent"
|
||||
MailSendFailed = "mail.send.failed"
|
||||
)
|
||||
|
||||
// OutcomeEvent is the body mail.send.sent and mail.send.failed carry.
|
||||
type OutcomeEvent struct {
|
||||
Send string `json:"send"`
|
||||
Account string `json:"account"`
|
||||
Outcome string `json:"outcome"`
|
||||
}
|
||||
|
||||
// Emit publishes one of this module's own events with a chosen message id (novox/hq ADR 0436 clauses 4,
|
||||
// 11): the real one (sender.go) wraps a process.Conn's own Emit; a test gives its own.
|
||||
type Emit func(eventType, id string, body any) error
|
||||
|
||||
// RetryDelays is how long after each not-taken attempt (1-indexed) the bus is asked to redeliver the
|
||||
// message, before the next attempt: 1, 5, 15 and 30 minutes for attempts 1 to 4 (novox/hq ADR 0436 clause
|
||||
// 10).
|
||||
var RetryDelays = []time.Duration{time.Minute, 5 * time.Minute, 15 * time.Minute, 30 * time.Minute}
|
||||
|
||||
// MostAttempts is how many times the sender hands a claimed send to the server before giving up: the fifth
|
||||
// failure records failed, and the bus's own max-deliveries (module.json's mail-sends) dead-letters the
|
||||
// message once it is terminated rather than acknowledged (novox/hq ADR 0436 clause 10).
|
||||
const MostAttempts = 5
|
||||
|
||||
// msgAction is what the caller (the message handler, sender.go) does with the queue message once
|
||||
// recordOutcome has written the decision: Ack (sent), NakWithDelay (retrying, not the last attempt yet) or
|
||||
// Term (failed, or not proven — the bus is never asked to redeliver those; a dead letter past the fifth
|
||||
// attempt is still reached by Term, since clause 10 records failed itself rather than waiting for the bus's
|
||||
// own max-deliveries to notice). actionLeave is none of these: the record's own write could not be kept
|
||||
// (even after a retry on a changed revision), so nothing is emitted and the message is left entirely to
|
||||
// the bus — a later redelivery finds the record still at its previous outcome and reconciles from there,
|
||||
// never a send silently announced sent, failed or retrying when it was not actually kept that way (novox/hq
|
||||
// review round 1 finding 5).
|
||||
type msgAction int
|
||||
|
||||
const (
|
||||
actionAck msgAction = iota
|
||||
actionRetry
|
||||
actionTerm
|
||||
actionLeave
|
||||
)
|
||||
|
||||
// recordOutcome writes the final decision for one claimed send once SendMail has run (or could not even be
|
||||
// attempted — the account vanished, or its password cannot be read, both passed in as a NotTaken
|
||||
// *SendError, exactly as a real SendMail failure would be: safe to retry), and says what the caller does
|
||||
// with the message next. Attempt (req's own, from the claim) decides retrying from failed on a not-taken
|
||||
// failure: the fifth attempt always fails rather than retries again (novox/hq ADR 0436 clauses 10, 11).
|
||||
func recordOutcome(store SendStore, emit Emit, req SendRequest, claimed SendDecision, rev uint64, sendErr error, now time.Time) (SendDecision, msgAction, time.Duration, error) {
|
||||
// write keeps d, retrying once on a changed revision (re-reading the send and trying again with its
|
||||
// fresh one) before giving up: kept is false only when the record's own write could not be kept even
|
||||
// after that retry, in which case the caller emits nothing and leaves the message to the bus entirely —
|
||||
// never announcing an outcome (sent, failed, retrying) that was not actually recorded (novox/hq review
|
||||
// round 1 finding 5). "Kept" is decided from settleSend's own returned revision, never from its error
|
||||
// alone: on a changed revision whose record already carries another writer's decision, settleSend
|
||||
// (through updateDecision) returns that other decision with revision 0 and a nil error — a nil error
|
||||
// is not success here, and without checking the revision the retry never ran at all, so the caller
|
||||
// could emit an outcome (and ack) while the record actually held someone else's state (novox/hq review
|
||||
// round 2, issue 1198).
|
||||
write := func(d SendDecision, final, tellWorthy bool) (result SendDecision, kept bool, outErr error) {
|
||||
var indexErr error
|
||||
if tellWorthy {
|
||||
// Indexed for a tell BEFORE the record carries the outcome that owes it (novox/hq review round
|
||||
// 1 finding 3): a crash between this and the write below must still leave it findable once the
|
||||
// outcome does land; tellSend itself tolerates finding it still sending, queued or retrying.
|
||||
indexErr = addTellPending(store, req.ID)
|
||||
}
|
||||
curReq, curRev := req, rev
|
||||
var newRev uint64
|
||||
var writeErr error
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
result, newRev, writeErr = settleSend(store, curReq, curRev, d)
|
||||
if newRev != 0 {
|
||||
break // this write landed
|
||||
}
|
||||
if attempt == 1 {
|
||||
break // retried once already; give up
|
||||
}
|
||||
if writeErr != nil && !errors.Is(writeErr, stdio.ErrChanged) {
|
||||
break // a hard failure, not a revision race: retrying blindly would not help
|
||||
}
|
||||
fresh, freshRev, ok, rerr := getSend(store, req.ID)
|
||||
if rerr != nil || !ok {
|
||||
break
|
||||
}
|
||||
curReq, curRev = fresh, freshRev
|
||||
}
|
||||
if newRev == 0 {
|
||||
reportErr := writeErr
|
||||
if reportErr == nil {
|
||||
reportErr = fmt.Errorf("the send %s's outcome could not be kept: another writer's decision already stands, even retried on its fresh revision", req.ID)
|
||||
}
|
||||
return d, false, errOr(indexErr, reportErr)
|
||||
}
|
||||
if final {
|
||||
// Only once the outcome is durably recorded is the sender-pending index's entry dropped: from
|
||||
// here the sender is done with it, one way or another (novox/hq issue 1199).
|
||||
if rmErr := removeSenderPending(store, req.ID); rmErr != nil {
|
||||
indexErr = errOr(indexErr, fmt.Errorf("dropping %s from the sender-pending index: %w", req.ID, rmErr))
|
||||
}
|
||||
}
|
||||
return result, true, indexErr
|
||||
}
|
||||
if sendErr == nil {
|
||||
d, kept, err := write(SendDecision{At: now, Outcome: "sent", Warrant: claimed.Warrant, Attempt: claimed.Attempt}, true, false)
|
||||
if !kept {
|
||||
return d, actionLeave, 0, err
|
||||
}
|
||||
_ = emit(MailSendSent, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
|
||||
return d, actionAck, 0, err
|
||||
}
|
||||
var se *SendError
|
||||
if !errors.As(sendErr, &se) {
|
||||
// SendMail itself never returns an error that is not a *SendError; a caller passing one of its own
|
||||
// (the account vanished, or its password cannot be read) is read as not taken, never as a refusal or
|
||||
// a proven send, which would be worse guesses than "safe to retry" (novox/hq ADR 0436 clause 10).
|
||||
se = &SendError{Outcome: NotTaken, Err: sendErr}
|
||||
}
|
||||
switch se.Outcome {
|
||||
case Refused:
|
||||
d, kept, err := write(SendDecision{At: now, Outcome: "failed: " + se.Err.Error(), Warrant: claimed.Warrant,
|
||||
Attempt: claimed.Attempt, Error: se.Err.Error()}, true, true)
|
||||
if !kept {
|
||||
return d, actionLeave, 0, err
|
||||
}
|
||||
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
|
||||
return d, actionTerm, 0, err
|
||||
case NotProven:
|
||||
d, kept, err := write(SendDecision{At: now, Outcome: "not proven", Warrant: claimed.Warrant, Attempt: claimed.Attempt,
|
||||
Error: se.Err.Error()}, true, true)
|
||||
if !kept {
|
||||
return d, actionLeave, 0, err
|
||||
}
|
||||
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
|
||||
return d, actionTerm, 0, err
|
||||
default: // NotTaken
|
||||
if claimed.Attempt >= MostAttempts {
|
||||
d, kept, err := write(SendDecision{At: now, Outcome: "failed: " + se.Err.Error(), Warrant: claimed.Warrant,
|
||||
Attempt: claimed.Attempt, Error: se.Err.Error()}, true, true)
|
||||
if !kept {
|
||||
return d, actionLeave, 0, err
|
||||
}
|
||||
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
|
||||
return d, actionTerm, 0, err
|
||||
}
|
||||
delay := RetryDelays[claimed.Attempt-1]
|
||||
d, kept, err := write(SendDecision{At: now, Outcome: "retrying", Warrant: claimed.Warrant, Attempt: claimed.Attempt,
|
||||
Error: se.Err.Error(), NextAttempt: now.Add(delay)}, false, false)
|
||||
if !kept {
|
||||
return d, actionLeave, 0, err
|
||||
}
|
||||
return d, actionRetry, delay, err // not a final outcome: no emit (clause 11 names only sent and failed)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- two more indices (novox/hq issue 1199), the same shape as the undecided index (issue 811) ------------------
|
||||
//
|
||||
// Neither the tools' nor the sender's own `sends` state lists every key it holds (mesh-sdk go/process's
|
||||
// State offers no such call, and listing it all on every health check or every look would grow with every
|
||||
// send ever sent, not with the few still live): a reserved key stands in for a list, exactly as the
|
||||
// undecided index already does.
|
||||
|
||||
// errOr is a, or b when a is nil: the first of two errors a caller that can only report one keeps (novox/hq
|
||||
// review round 1 finding 3), so an index-bookkeeping failure is never silently dropped once there is also a
|
||||
// record-write failure to report, and vice versa.
|
||||
func errOr(a, b error) error {
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// idIndex is the shape a send-id index keeps: generic across all three (undecided, sender-pending,
|
||||
// tells-pending).
|
||||
type idIndex struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
// addToIndex adds id to the index at key, retrying its compare-and-set write on a race. A stored index
|
||||
// that cannot be parsed is never overwritten (which would silently drop every id it already named): it is
|
||||
// reported as an error naming the key instead (novox/hq issue 811, review round 3).
|
||||
func addToIndex(store SendStore, key, id string) error {
|
||||
for {
|
||||
raw, rev, ok, err := store.Get(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var idx idIndex
|
||||
if ok {
|
||||
if err := json.Unmarshal(raw, &idx); err != nil {
|
||||
return fmt.Errorf("the index %q cannot be parsed: %w", key, err)
|
||||
}
|
||||
for _, existing := range idx.IDs {
|
||||
if existing == id {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
idx.IDs = append(idx.IDs, id)
|
||||
if !ok {
|
||||
if _, err := store.Create(key, idx); err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, err := store.Update(key, idx, rev); err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// removeFromIndex drops id from the index at key, retrying its compare-and-set write on a race. An id
|
||||
// never in the index (already absent) is left as it is.
|
||||
func removeFromIndex(store SendStore, key, id string) error {
|
||||
for {
|
||||
raw, rev, ok, err := store.Get(key)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
var idx idIndex
|
||||
if err := json.Unmarshal(raw, &idx); err != nil {
|
||||
return fmt.Errorf("the index %q cannot be parsed: %w", key, err)
|
||||
}
|
||||
at := -1
|
||||
for i, existing := range idx.IDs {
|
||||
if existing == id {
|
||||
at = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if at == -1 {
|
||||
return nil
|
||||
}
|
||||
idx.IDs = append(idx.IDs[:at], idx.IDs[at+1:]...)
|
||||
if _, err := store.Update(key, idx, rev); err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// indexIDs is every id the index at key currently names.
|
||||
func indexIDs(store SendStore, key string) ([]string, error) {
|
||||
raw, _, ok, err := store.Get(key)
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
var idx idIndex
|
||||
if err := json.Unmarshal(raw, &idx); err != nil {
|
||||
return nil, fmt.Errorf("the index %q cannot be parsed: %w", key, err)
|
||||
}
|
||||
return idx.IDs, nil
|
||||
}
|
||||
|
||||
// senderPendingKey indexes every send the sender currently owns — queued, retrying or sending — so
|
||||
// mail_health's overdue check (Overdue, below) finds them without listing `sends` whole.
|
||||
const senderPendingKey = "sender-pending"
|
||||
|
||||
func addSenderPending(store SendStore, id string) error {
|
||||
return addToIndex(store, senderPendingKey, id)
|
||||
}
|
||||
func removeSenderPending(store SendStore, id string) error {
|
||||
return removeFromIndex(store, senderPendingKey, id)
|
||||
}
|
||||
func senderPendingIDs(store SendStore) ([]string, error) { return indexIDs(store, senderPendingKey) }
|
||||
|
||||
// tellsPendingKey indexes every send still owing the sender's own tell (needsTell, below), so its own
|
||||
// look (sender.go) finds them without listing `sends` whole.
|
||||
const tellsPendingKey = "tells-pending"
|
||||
|
||||
func addTellPending(store SendStore, id string) error { return addToIndex(store, tellsPendingKey, id) }
|
||||
func removeTellPending(store SendStore, id string) error {
|
||||
return removeFromIndex(store, tellsPendingKey, id)
|
||||
}
|
||||
func tellPendingIDs(store SendStore) ([]string, error) { return indexIDs(store, tellsPendingKey) }
|
||||
|
||||
// ---- overdue (novox/hq ADR 0436 clause 11, issue 1199) ------------------------------------------------------
|
||||
|
||||
// OverdueAfter is how long a send may sit queued, or wait past its own next attempt, before mail_health
|
||||
// calls it unhealthy (novox/hq ADR 0436 clause 11).
|
||||
const OverdueAfter = 10 * time.Minute
|
||||
|
||||
// Overdue is every send the sender currently owns (senderPendingIDs) that is overdue: queued more than
|
||||
// OverdueAfter ago, or retrying more than OverdueAfter past its own next attempt. A send waiting for its
|
||||
// next attempt within that window, or one actually being sent, is never overdue (novox/hq ADR 0436 clause
|
||||
// 11).
|
||||
func Overdue(store SendStore, now time.Time) ([]SendRequest, error) {
|
||||
ids, err := senderPendingIDs(store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []SendRequest
|
||||
for _, id := range ids {
|
||||
req, _, ok, err := getSend(store, id)
|
||||
if err != nil || !ok || req.Decided == nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case req.Decided.Outcome == "queued" && now.Sub(req.Decided.At) > OverdueAfter:
|
||||
out = append(out, req)
|
||||
case req.Decided.Outcome == "retrying" && now.Sub(req.Decided.NextAttempt) > OverdueAfter:
|
||||
out = append(out, req)
|
||||
}
|
||||
}
|
||||
claimed.Decided = &final
|
||||
if _, err := store.Update(req.ID, claimed, newRev); err != nil {
|
||||
// The claim stands either way: a person's Approve is acted on once. The caller logs this; it is not retried.
|
||||
return final, fmt.Errorf("the send %s was %s, and that could not be kept (%w)", req.ID, final.Outcome, err)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- telling the operator (novox/hq ADR 0436 clause 11, issue 1199) ----------------------------------------
|
||||
//
|
||||
// Two separate tells, each kept to once on its own field: the sender's own, for a failed, refused or
|
||||
// not-proven send (Tell); the tools' own, for an overdue one — still queued or retrying, its sender not
|
||||
// having taken it yet (WaitsTell). Neither ever names an address, held to the operator channel's content
|
||||
// rule by using only the account's own name.
|
||||
|
||||
// TellOperator calls seat:operator-channel.tell once with the one line to say (novox/hq ADR 0380 §8). The
|
||||
// real ones wrap a process.Conn's own Call (sender.go) or stdio.Ask (main.go); a test gives its own.
|
||||
type TellOperator func(headline string) error
|
||||
|
||||
// needsTell says whether a decided send's outcome owes the operator the sender's own tell (novox/hq ADR
|
||||
// 0436 clause 11): every outcome after the Approve but sent — failed, refused or not proven.
|
||||
func needsTell(outcome string) bool {
|
||||
return outcome == "not proven" || strings.HasPrefix(outcome, "failed: ") || strings.HasPrefix(outcome, "refused: ")
|
||||
}
|
||||
|
||||
// tellReason is the sender's own one line for a failed, refused or not-proven send (novox/hq ADR 0436
|
||||
// clause 11).
|
||||
func tellReason(account, outcome string) string {
|
||||
if outcome == "not proven" {
|
||||
return fmt.Sprintf("The mail you approved from %s may not have been sent; its sent folder says", account)
|
||||
}
|
||||
return final, nil
|
||||
reason := outcome
|
||||
for _, prefix := range []string{"failed: ", "refused: "} {
|
||||
if r, ok := strings.CutPrefix(outcome, prefix); ok {
|
||||
reason = r
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("The mail you approved from %s was not sent: %s", account, reason)
|
||||
}
|
||||
|
||||
// tellSend attempts the sender's own tell for one failed, refused or not-proven send, kept to once by
|
||||
// Tell: telling then told, by compare-and-set, on the record itself (novox/hq ADR 0436 clause 11). A tell
|
||||
// the channel refuses, or that nothing answers, leaves telling, so the next look (the sender's own ticker,
|
||||
// at least every 10 minutes) tries it again; a record already told, or whose outcome no longer needs one,
|
||||
// is dropped from the index without being told again.
|
||||
func tellSend(store SendStore, tell TellOperator, id string, now time.Time) error {
|
||||
req, rev, ok, err := getSend(store, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || req.Decided == nil {
|
||||
_ = removeTellPending(store, id)
|
||||
return nil
|
||||
}
|
||||
switch req.Decided.Outcome {
|
||||
case "sending", "queued", "retrying":
|
||||
// Indexed for a tell before the outcome that owes it had actually landed (addTellPending now runs
|
||||
// before the write, novox/hq review round 1 finding 3): not decided yet whether a tell is owed.
|
||||
// Left in the index; the next look finds it once the write lands.
|
||||
return nil
|
||||
}
|
||||
if !needsTell(req.Decided.Outcome) || req.Decided.Tell == "told" {
|
||||
_ = removeTellPending(store, id)
|
||||
return nil
|
||||
}
|
||||
d := *req.Decided
|
||||
if d.Tell != "telling" {
|
||||
d.Tell = "telling"
|
||||
next := req
|
||||
next.Decided = &d
|
||||
newRev, err := store.Update(id, next, rev)
|
||||
if err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return nil // raced with another writer; the next look sorts it out
|
||||
}
|
||||
return err
|
||||
}
|
||||
rev = newRev
|
||||
}
|
||||
if err := tell(tellReason(req.Account, d.Outcome)); err != nil {
|
||||
return nil // left telling; tried again on the next look
|
||||
}
|
||||
d.Tell = "told"
|
||||
next := req
|
||||
next.Decided = &d
|
||||
if _, err := store.Update(id, next, rev); err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
_ = removeTellPending(store, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitsReason is the tools' own one line for an overdue send (novox/hq ADR 0436 clause 11).
|
||||
func waitsReason(account string) string {
|
||||
return fmt.Sprintf("The mail you approved from %s waits: its sender has not taken it", account)
|
||||
}
|
||||
|
||||
// tellWaits attempts the tools' own "waits" tell for one overdue send, kept to once by WaitsTell — the
|
||||
// same telling/told sequence as tellSend, on the record's own separate field, since the two tells are
|
||||
// never the same one (novox/hq ADR 0436 clause 11).
|
||||
func tellWaits(store SendStore, tell TellOperator, id string, now time.Time) error {
|
||||
req, rev, ok, err := getSend(store, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || req.Decided == nil || (req.Decided.Outcome != "queued" && req.Decided.Outcome != "retrying") || req.Decided.WaitsTell == "told" {
|
||||
return nil
|
||||
}
|
||||
d := *req.Decided
|
||||
if d.WaitsTell != "telling" {
|
||||
d.WaitsTell = "telling"
|
||||
next := req
|
||||
next.Decided = &d
|
||||
newRev, err := store.Update(id, next, rev)
|
||||
if err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
rev = newRev
|
||||
}
|
||||
if err := tell(waitsReason(req.Account)); err != nil {
|
||||
return nil
|
||||
}
|
||||
d.WaitsTell = "told"
|
||||
next := req
|
||||
next.Decided = &d
|
||||
if _, err := store.Update(id, next, rev); err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- reading the status -----------------------------------------------------------------------------------------
|
||||
|
||||
// ReadSendStatus is where a send asked with mail_send stands, by its ask id: waiting for the operator, sent,
|
||||
// declined, ended without a choice (expired, refused, cancelled), or not proven. Read from the router's own
|
||||
// record; when the router's word is a close one that was not decided yet, this is also where a send is first
|
||||
// decided (and so sent), exactly once (Decide).
|
||||
func ReadSendStatus(store SendStore, bus AskBus, lookup AccountLookup, send SendActor, id string, now time.Time) (map[string]any, error) {
|
||||
// ReadSendStatus is where a send asked with mail_send stands, answered only to the caller that asked for it
|
||||
// (novox/hq ADR 0440 §7, issue 1261): an id no send is kept under, or a send whose own caller differs from the
|
||||
// one asking, answers status "unknown" and nothing else of it — never an error, and never the account,
|
||||
// recipients or subject of a send that is not the caller's own. The sweep, which settles a send regardless of
|
||||
// who asked for it, calls readSendStatus directly instead, never through this caller check.
|
||||
func ReadSendStatus(store SendStore, bus AskBus, publish QueuePublish, id, caller string, now time.Time) (map[string]any, error) {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
req, rev, ok, err := getSend(store, trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the send %q cannot be read now (%v); ask again", trimmed, err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no send %q is kept: the id is the one mail_send gave", trimmed)
|
||||
if !ok || req.Caller != caller {
|
||||
return map[string]any{"status": "unknown"}, nil
|
||||
}
|
||||
return readSendStatus(store, bus, publish, req, rev, now)
|
||||
}
|
||||
|
||||
// readSendStatus is ReadSendStatus's own reading of one send already fetched (req, rev): waiting for the
|
||||
// operator, sent, declined, ended without a choice (expired, refused, cancelled), or not proven. Read from the
|
||||
// router's own record; when the router's word is a close one that was not decided yet, this is also where a
|
||||
// send is first decided (and so sent), exactly once (Decide). It never checks who is asking: ReadSendStatus
|
||||
// does that before calling it, and the sweep (which settles a send no matter who asked for it) calls it
|
||||
// directly.
|
||||
func readSendStatus(store SendStore, bus AskBus, publish QueuePublish, req SendRequest, rev uint64, now time.Time) (map[string]any, error) {
|
||||
out := map[string]any{"ask": req.ID, "account": req.Account, "to": req.To, "subject": req.Subject,
|
||||
"asked": req.At.Format(time.RFC3339)}
|
||||
if req.Decided != nil {
|
||||
@@ -696,7 +1353,7 @@ func ReadSendStatus(store SendStore, bus AskBus, lookup AccountLookup, send Send
|
||||
if now.Sub(req.At) > SendAskLasts {
|
||||
d, _, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "ended: never heard"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the send %q could not be settled as ended (%v); ask again", trimmed, err)
|
||||
return nil, fmt.Errorf("the send %q could not be settled as ended (%v); ask again", req.ID, err)
|
||||
}
|
||||
reportDecision(out, d, now)
|
||||
return out, nil
|
||||
@@ -713,7 +1370,7 @@ func ReadSendStatus(store SendStore, bus AskBus, lookup AccountLookup, send Send
|
||||
out["status"], out["words"] = "waiting", "the operator has not answered yet"
|
||||
return out, nil
|
||||
}
|
||||
d, err := Decide(store, lookup, send, *rec.Warrant, now)
|
||||
d, err := Decide(store, publish, *rec.Warrant, now)
|
||||
if err != nil {
|
||||
out["status"], out["words"] = "not proven", err.Error()
|
||||
return out, nil
|
||||
@@ -726,9 +1383,17 @@ func ReadSendStatus(store SendStore, bus AskBus, lookup AccountLookup, send Send
|
||||
// and the words that explain it. now is when it is asked, not when the send was decided: a claim still "sending"
|
||||
// longer than StaleSendAfter with no final write is "not proven" (the send may or may not have gone out), never
|
||||
// "waiting" forever — and it is never retried automatically either way (novox/hq issue 790, review round 1).
|
||||
// reportDecision's status word is one of: waiting (for the operator, or for the sender — approved, queued,
|
||||
// being sent or retrying, each said apart in words), sent, failed, refused, not proven, declined or expired
|
||||
// (novox/hq ADR 0436 clause 12, issue 1199) — the whole vocabulary mail_send_status answers.
|
||||
func reportDecision(out map[string]any, d SendDecision, now time.Time) {
|
||||
out["at"] = d.At.Format(time.RFC3339)
|
||||
switch {
|
||||
case d.Outcome == "queued":
|
||||
// Recorded queued by the one compare-and-set the Approve makes (novox/hq ADR 0436 clause 8): there
|
||||
// is no separate "approved" state in a send's life. Published to mail-sends already, or the sweep
|
||||
// will publish it again once a short grace has passed.
|
||||
out["status"], out["words"] = "waiting", "approved and waiting for the sender; ask again shortly"
|
||||
case d.Outcome == "sent":
|
||||
out["status"], out["words"] = "sent", "sent on the operator's Approve ("+d.Warrant+")"
|
||||
case d.Outcome == "declined":
|
||||
@@ -738,17 +1403,23 @@ func reportDecision(out map[string]any, d SendDecision, now time.Time) {
|
||||
out["words"] = "not proven: the send may or may not have gone out; the operator's Approve was being acted " +
|
||||
"on at " + d.At.Format(time.RFC3339) + ", with no final word since"
|
||||
case d.Outcome == "sending":
|
||||
out["status"], out["words"] = "waiting", "the operator's Approve is being acted on; ask again shortly"
|
||||
out["status"], out["words"] = "waiting", "being sent; ask again shortly"
|
||||
case d.Outcome == "retrying":
|
||||
out["status"] = "waiting"
|
||||
out["words"] = "waiting for its next attempt, at " + d.NextAttempt.Format(time.RFC3339) + ": " + d.Error
|
||||
case d.Outcome == "not proven":
|
||||
out["status"] = "not proven"
|
||||
out["words"] = "not proven: the send may or may not have gone out: " + d.Error
|
||||
case d.Outcome == "ended: never heard":
|
||||
out["status"], out["words"] = "ended", "its ask to the operator was never heard, so it was not sent"
|
||||
out["status"], out["words"] = "expired", "its ask to the operator was never heard, so it was not sent"
|
||||
case strings.HasPrefix(d.Outcome, "failed: "):
|
||||
out["status"], out["words"] = "failed", "nothing reached the server: "+strings.TrimPrefix(d.Outcome, "failed: ")
|
||||
case strings.HasPrefix(d.Outcome, "ended: "):
|
||||
out["status"], out["words"] = "ended", "nothing was sent: "+strings.TrimPrefix(d.Outcome, "ended: ")
|
||||
out["status"], out["words"] = "expired", "nothing was sent: "+strings.TrimPrefix(d.Outcome, "ended: ")
|
||||
case strings.HasPrefix(d.Outcome, "refused: "):
|
||||
out["status"], out["words"] = "not proven", "nothing was sent: "+strings.TrimPrefix(d.Outcome, "refused: ")
|
||||
out["status"], out["words"] = "refused", "nothing was sent: "+strings.TrimPrefix(d.Outcome, "refused: ")
|
||||
default:
|
||||
out["status"], out["words"] = "ended", d.Outcome
|
||||
out["status"], out["words"] = "expired", d.Outcome
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,35 +1429,64 @@ func reportDecision(out map[string]any, d SendDecision, now time.Time) {
|
||||
// the same Decide that mail_send_status uses: an approved send goes out as soon as the operator answers, never
|
||||
// only when something later happens to call mail_send_status (novox/hq ADR 0361 §2, review round 1 of issue 790).
|
||||
func (b *Bundle) Heard(w asks.Warrant) {
|
||||
if _, err := Decide(b.sends(), b.lookupAccount, b.sendActor(), w, b.now()); err != nil {
|
||||
if _, err := Decide(b.sends(), b.queuePublish(), w, b.now()); err != nil {
|
||||
if b.Logf != nil {
|
||||
b.Logf("[mesh-mail] the warrant for the send %s could not be acted on: %v", w.Ask, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sweep reads only the sends the undecided index still names, and asks the router's own record for each one's
|
||||
// answer, the same path mail_send_status takes (ReadSendStatus): an Approve recorded while this module did not
|
||||
// hear it directly (down, or not yet subscribed) is still acted on, once. Unlike reading every send ever kept, its
|
||||
// work grows only with the sends still undecided, never with every one a module has ever sent (novox/hq ADR 0361
|
||||
// §2, review round 1 of issue 790; the index, issue 811).
|
||||
// QueuedGrace is how long a newly queued send is given to be published before the sweep considers it
|
||||
// possibly stuck and publishes it again: long enough that the tools' own publish, right after the same
|
||||
// compare-and-set write, is never raced by the sweep under ordinary load (novox/hq ADR 0436 clause 8, as
|
||||
// decided to replace the two-state approved/queued design, review round 1).
|
||||
const QueuedGrace = time.Minute
|
||||
|
||||
// Sweep does two things, neither reading every send ever kept (its work grows only with the few still
|
||||
// live, never with history): a send the undecided index still names and that is not yet decided at all is
|
||||
// asked about the same way mail_send_status does (ReadSendStatus) — an Approve recorded while this module
|
||||
// did not hear it directly (down, or not yet subscribed) is still acted on, once (novox/hq ADR 0361 §2,
|
||||
// review round 1 of issue 790; the index, issue 811); and a send the sender-pending index names that is
|
||||
// still "queued" past QueuedGrace — its publish to mail-sends may have failed, or the tools stopped before
|
||||
// it ever ran — is published again, the send id still its message id, so the duplicate window and the
|
||||
// sender's own claim keep it one send either way (novox/hq ADR 0436 clause 8, review round 1).
|
||||
func (b *Bundle) Sweep(now time.Time) {
|
||||
store := b.sends()
|
||||
ids, err := undecidedIDs(store)
|
||||
undecided, err := undecidedIDs(store)
|
||||
if err != nil {
|
||||
if b.Logf != nil {
|
||||
b.Logf("[mesh-mail] the undecided index cannot be read: %v", err)
|
||||
}
|
||||
} else {
|
||||
for _, id := range undecided {
|
||||
req, rev, ok, err := getSend(store, id)
|
||||
if err != nil || !ok || req.Decided != nil {
|
||||
continue
|
||||
}
|
||||
// 1261/AC6: the sweep settles a send regardless of who asked for it — readSendStatus directly,
|
||||
// never ReadSendStatus's caller check.
|
||||
if _, err := readSendStatus(store, b.askBus(), b.queuePublish(), req, rev, now); err != nil {
|
||||
if b.Logf != nil {
|
||||
b.Logf("[mesh-mail] sweeping the send %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pending, err := senderPendingIDs(store)
|
||||
if err != nil {
|
||||
if b.Logf != nil {
|
||||
b.Logf("[mesh-mail] the sender-pending index cannot be read: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
for _, id := range pending {
|
||||
req, _, ok, err := getSend(store, id)
|
||||
if err != nil || !ok || req.Decided != nil {
|
||||
if err != nil || !ok || req.Decided == nil || req.Decided.Outcome != "queued" || now.Sub(req.Decided.At) < QueuedGrace {
|
||||
continue
|
||||
}
|
||||
if _, err := ReadSendStatus(store, b.askBus(), b.lookupAccount, b.sendActor(), id, now); err != nil {
|
||||
if err := b.queuePublish()(req.ID, QueuedSend{Send: req.ID, Account: req.Account, Ask: req.ID}); err != nil {
|
||||
if b.Logf != nil {
|
||||
b.Logf("[mesh-mail] sweeping the send %s: %v", id, err)
|
||||
b.Logf("[mesh-mail] republishing the queued send %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,47 @@ func (r *sendRouter) published1() int {
|
||||
return len(r.published)
|
||||
}
|
||||
|
||||
// ---- a fake work-queue publish, as the tools reach mail-sends (novox/hq ADR 0436 clause 8) ----------------------
|
||||
|
||||
type queuedMsg struct {
|
||||
id string
|
||||
body QueuedSend
|
||||
}
|
||||
|
||||
type memQueue struct {
|
||||
mu sync.Mutex
|
||||
published []queuedMsg
|
||||
failErr error
|
||||
}
|
||||
|
||||
func newMemQueue() *memQueue { return &memQueue{} }
|
||||
|
||||
func (q *memQueue) publish(id string, body any) error {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if q.failErr != nil {
|
||||
return q.failErr
|
||||
}
|
||||
b, _ := body.(QueuedSend)
|
||||
q.published = append(q.published, queuedMsg{id: id, body: b})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *memQueue) count() int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return len(q.published)
|
||||
}
|
||||
|
||||
func (q *memQueue) last() queuedMsg {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if len(q.published) == 0 {
|
||||
return queuedMsg{}
|
||||
}
|
||||
return q.published[len(q.published)-1]
|
||||
}
|
||||
|
||||
// ---- an in-process fake SMTP server, speaking the protocol over a real socket -------------------------------------
|
||||
|
||||
type receivedMail struct {
|
||||
@@ -375,11 +416,13 @@ func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// (3) On Approve, the fake SMTP server receives exactly one message, even if the warrant is delivered twice.
|
||||
func TestOnApproveTheFakeSMTPServerReceivesExactlyOneMessageEvenDeliveredTwice(t *testing.T) {
|
||||
fake := newFakeSMTPServer(t)
|
||||
// 1194/AC1: On Approve, the send is queued for the sender exactly once, even if the warrant is delivered twice
|
||||
// (novox/hq ADR 0436 clause 8). Since issue 1194 the tools never dial an outgoing server: they record approved
|
||||
// and queue the send for the sender process, which alone performs it (issue 1196, 1198).
|
||||
func TestOnApproveTheSendIsQueuedExactlyOnceEvenDeliveredTwice(t *testing.T) {
|
||||
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
b.Dial = fake.dialer()
|
||||
queue := newMemQueue()
|
||||
b.Queue = queue.publish
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
||||
@@ -393,44 +436,41 @@ func TestOnApproveTheFakeSMTPServerReceivesExactlyOneMessageEvenDeliveredTwice(t
|
||||
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
|
||||
router.close(w)
|
||||
|
||||
lookup := b.lookupAccount
|
||||
actor := b.sendActor()
|
||||
if _, err := Decide(sends, lookup, actor, w, judgedAt.Add(2*time.Minute)); err != nil {
|
||||
publish := b.queuePublish()
|
||||
if _, err := Decide(sends, publish, w, judgedAt.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("deciding: %v", err)
|
||||
}
|
||||
if n := fake.count(); n != 1 {
|
||||
t.Fatalf("the fake server received %d message(s) after one Approve", n)
|
||||
if n := queue.count(); n != 1 {
|
||||
t.Fatalf("the queue received %d message(s) after one Approve", n)
|
||||
}
|
||||
// The warrant delivered a second time: still exactly one message.
|
||||
if _, err := Decide(sends, lookup, actor, w, judgedAt.Add(3*time.Minute)); err != nil {
|
||||
// The warrant delivered a second time: still queued exactly once.
|
||||
if _, err := Decide(sends, publish, w, judgedAt.Add(3*time.Minute)); err != nil {
|
||||
t.Fatalf("deciding again: %v", err)
|
||||
}
|
||||
if n := fake.count(); n != 1 {
|
||||
t.Fatalf("the fake server received %d message(s) after the warrant was delivered twice", n)
|
||||
if n := queue.count(); n != 1 {
|
||||
t.Fatalf("the queue received %d message(s) after the warrant was delivered twice", n)
|
||||
}
|
||||
got := fake.last()
|
||||
if got.from != "jschoubben@novox.be" || len(got.to) != 2 || got.to[0] != "ops@example.org" || got.to[1] != "lead@example.org" {
|
||||
t.Fatalf("received %+v", got)
|
||||
}
|
||||
if !strings.Contains(got.data, "The nightly gate is red") || !strings.Contains(got.data, "The merge gate failed on the resolver change") {
|
||||
t.Fatalf("the message sent: %q", got.data)
|
||||
got := queue.last()
|
||||
if got.id != id || got.body.Send != id || got.body.Account != "mesh" || got.body.Ask != id {
|
||||
t.Fatalf("queued %+v, want the send id %s as the message id, account and ask", got, id)
|
||||
}
|
||||
|
||||
// mail_send_status reports it sent, and reports the same thing again without sending a third time.
|
||||
// mail_send_status reports it waiting on the sender, and reports the same thing again without queueing a
|
||||
// third time.
|
||||
text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
|
||||
if isErr || !strings.Contains(text, `"status":"sent"`) {
|
||||
if isErr || !strings.Contains(text, `"status":"waiting"`) {
|
||||
t.Fatalf("%v %s", isErr, text)
|
||||
}
|
||||
if n := fake.count(); n != 1 {
|
||||
t.Fatalf("mail_send_status sent again: %d", n)
|
||||
if n := queue.count(); n != 1 {
|
||||
t.Fatalf("mail_send_status queued again: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// (4) On Decline, nothing is sent, and the status says declined.
|
||||
func TestOnDeclineNothingIsSentAndTheStatusSaysDeclined(t *testing.T) {
|
||||
fake := newFakeSMTPServer(t)
|
||||
// 1194/AC3: On Decline, nothing is queued, and the status says declined.
|
||||
func TestOnDeclineNothingIsQueuedAndTheStatusSaysDeclined(t *testing.T) {
|
||||
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
b.Dial = fake.dialer()
|
||||
queue := newMemQueue()
|
||||
b.Queue = queue.publish
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
||||
@@ -443,11 +483,11 @@ func TestOnDeclineNothingIsSentAndTheStatusSaysDeclined(t *testing.T) {
|
||||
|
||||
w := router.chose(id, OptionDecline, judgedAt.Add(time.Minute))
|
||||
router.close(w)
|
||||
if _, err := Decide(sends, b.lookupAccount, b.sendActor(), w, judgedAt.Add(2*time.Minute)); err != nil {
|
||||
if _, err := Decide(sends, b.queuePublish(), w, judgedAt.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("deciding: %v", err)
|
||||
}
|
||||
if n := fake.count(); n != 0 {
|
||||
t.Fatalf("the fake server received %d message(s) on Decline", n)
|
||||
if n := queue.count(); n != 0 {
|
||||
t.Fatalf("the queue received %d message(s) on Decline", n)
|
||||
}
|
||||
|
||||
text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
|
||||
@@ -474,12 +514,12 @@ func TestMailSendRefusesAnAccountWithNoOutgoingServer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// (5) An approved send goes out once the warrant reaches the module through the hear handler alone — never
|
||||
// through mail_send_status (novox/hq ADR 0361 §2, review round 1 of issue 790).
|
||||
func TestAnApprovedSendGoesOutOnceWithNoStatusCall(t *testing.T) {
|
||||
fake := newFakeSMTPServer(t)
|
||||
// 1194/AC1: An approved send is queued once the warrant reaches the module through the hear handler alone —
|
||||
// never through mail_send_status (novox/hq ADR 0361 §2, review round 1 of issue 790).
|
||||
func TestAnApprovedSendIsQueuedOnceWithNoStatusCall(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
b.Dial = fake.dialer()
|
||||
queue := newMemQueue()
|
||||
b.Queue = queue.publish
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
||||
@@ -494,21 +534,21 @@ func TestAnApprovedSendGoesOutOnceWithNoStatusCall(t *testing.T) {
|
||||
router.close(w)
|
||||
|
||||
b.Heard(w) // the warrant delivered through the hear handler only
|
||||
if n := fake.count(); n != 1 {
|
||||
t.Fatalf("the fake server received %d message(s) after the warrant was heard once", n)
|
||||
if n := queue.count(); n != 1 {
|
||||
t.Fatalf("the queue received %d message(s) after the warrant was heard once", n)
|
||||
}
|
||||
b.Heard(w) // the same warrant delivered a second time
|
||||
if n := fake.count(); n != 1 {
|
||||
t.Fatalf("the fake server received %d message(s) after the warrant was heard twice", n)
|
||||
if n := queue.count(); n != 1 {
|
||||
t.Fatalf("the queue received %d message(s) after the warrant was heard twice", n)
|
||||
}
|
||||
}
|
||||
|
||||
// (6) The sweep acts on a send the operator approved while this module was never told directly: it reads the
|
||||
// router's own record, the same path mail_send_status takes (novox/hq issue 790, review round 1).
|
||||
// 1194/AC1: The sweep acts on a send the operator approved while this module was never told directly: it reads
|
||||
// the router's own record, the same path mail_send_status takes (novox/hq issue 790, review round 1).
|
||||
func TestSweepActsOnAnApprovedSendItWasNotToldAboutDirectly(t *testing.T) {
|
||||
fake := newFakeSMTPServer(t)
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
b.Dial = fake.dialer()
|
||||
queue := newMemQueue()
|
||||
b.Queue = queue.publish
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
||||
@@ -523,12 +563,66 @@ func TestSweepActsOnAnApprovedSendItWasNotToldAboutDirectly(t *testing.T) {
|
||||
router.close(w) // only the router's record holds the answer; mesh-mail is never told directly
|
||||
|
||||
b.Sweep(judgedAt.Add(2 * time.Minute))
|
||||
if n := fake.count(); n != 1 {
|
||||
if n := queue.count(); n != 1 {
|
||||
t.Fatalf("the sweep did not act on the approved send: %d message(s)", n)
|
||||
}
|
||||
b.Sweep(judgedAt.Add(3 * time.Minute))
|
||||
if n := fake.count(); n != 1 {
|
||||
t.Fatalf("a second sweep sent again: %d message(s)", n)
|
||||
// A second sweep, still within the just-queued record's own grace, does not publish it again (past the
|
||||
// grace, it would: TestSweepPublishesAgainAQueuedRecordPastItsGrace, novox/hq review round 1).
|
||||
b.Sweep(judgedAt.Add(2*time.Minute + QueuedGrace/2))
|
||||
if n := queue.count(); n != 1 {
|
||||
t.Fatalf("a second sweep within the grace queued again: %d message(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 1194/AC2 (as decided to replace the two-state approved/queued design, review round 1): the sweep
|
||||
// republishes a queued, unclaimed record whose queued time is older than QueuedGrace, the send id still
|
||||
// its message id — the stream's duplicate window and the sender's own claim keep it one send either way
|
||||
// (proved end to end over a real bus by the module-whole acceptance test, issue 1198 AC4).
|
||||
func TestSweepPublishesAgainAQueuedRecordPastItsGrace(t *testing.T) {
|
||||
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
queue := newMemQueue()
|
||||
queue.failErr = errors.New("the bus is down")
|
||||
b.Queue = queue.publish
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
|
||||
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
|
||||
router.close(w)
|
||||
decidedAt := judgedAt.Add(2 * time.Minute)
|
||||
if _, err := Decide(sends, b.queuePublish(), w, decidedAt); err != nil {
|
||||
t.Fatalf("deciding: %v", err)
|
||||
}
|
||||
if n := queue.count(); n != 0 {
|
||||
t.Fatalf("the queue received %d message(s) despite failing", n)
|
||||
}
|
||||
// There is no "approved" state: the record is already "queued", its own publish just failed.
|
||||
req, _, ok, err := getSend(sends, id)
|
||||
if err != nil || !ok || req.Decided == nil || req.Decided.Outcome != "queued" {
|
||||
t.Fatalf("left queued despite the publish failing: %v %v %+v", err, ok, req.Decided)
|
||||
}
|
||||
|
||||
queue.failErr = nil
|
||||
// Within its grace, the sweep leaves a freshly queued record alone.
|
||||
b.Sweep(decidedAt.Add(QueuedGrace / 2))
|
||||
if n := queue.count(); n != 0 {
|
||||
t.Fatalf("the sweep republished within the grace: %d message(s)", n)
|
||||
}
|
||||
|
||||
// Past its grace, the sweep publishes the still-queued send again.
|
||||
b.Sweep(decidedAt.Add(QueuedGrace + time.Second))
|
||||
if n := queue.count(); n != 1 {
|
||||
t.Fatalf("the sweep did not republish the queued send past its grace: %d message(s)", n)
|
||||
}
|
||||
got := queue.last()
|
||||
if got.id != id || got.body.Send != id {
|
||||
t.Fatalf("republished %+v, want the send id as the message id", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,26 +684,15 @@ func TestMailSendRefusesAControlCharacterInTheSubjectOrAnAddress(t *testing.T) {
|
||||
}
|
||||
|
||||
// (9) Rcpt takes the parsed address alone; the To header takes the formatted address (novox/hq issue 790, review
|
||||
// round 1).
|
||||
// round 1). Tested directly against SendMail (smtp.go): since issue 1194 the tools never dial an outgoing
|
||||
// server themselves — Decide only records approved and queues the send for the sender (issue 1196, 1198), which
|
||||
// is where SendMail is actually called from.
|
||||
func TestSendUsesTheParsedAddressForRcptAndTheFormattedAddressForTheToHeader(t *testing.T) {
|
||||
fake := newFakeSMTPServer(t)
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
b.Dial = fake.dialer()
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
args := map[string]any{"account": "mesh", "to": []any{"Ops Team <ops@example.org>"},
|
||||
"subject": "Status", "body": "body"}
|
||||
text, isErr := rt.call(SendTool, "novox.triage", args)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
acc := Account{Name: "mesh", Address: "jschoubben@novox.be", Outgoing: &Server{Host: "127.0.0.1", Port: 25, Security: "tls"}}
|
||||
if err := SendMail(fake.dialer(), acc, "the-password", []string{"Ops Team <ops@example.org>"}, "Status", "body", judgedAt, "send-abc123"); err != nil {
|
||||
t.Fatalf("SendMail: %v", err)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
|
||||
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
|
||||
router.close(w)
|
||||
b.Heard(w)
|
||||
|
||||
got := fake.last()
|
||||
if len(got.to) != 1 || got.to[0] != "ops@example.org" {
|
||||
@@ -660,8 +743,8 @@ func TestASendWhoseAskNeverPublishedEndsAfterTheAskWouldHaveLasted(t *testing.T)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
if !strings.Contains(text, `"status":"ended"`) {
|
||||
t.Fatalf("does not read ended once the ask would have lasted: %s", text)
|
||||
if !strings.Contains(text, `"status":"expired"`) {
|
||||
t.Fatalf("does not read expired once the ask would have lasted: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "its ask to the operator was never heard, so it was not sent") {
|
||||
t.Fatalf("does not say why: %s", text)
|
||||
@@ -671,9 +754,7 @@ func TestASendWhoseAskNeverPublishedEndsAfterTheAskWouldHaveLasted(t *testing.T)
|
||||
// (11) The sweep reads only the sends still undecided, by the undecided index, not every send ever kept: a send
|
||||
// already decided is never asked about again (novox/hq issue 811).
|
||||
func TestSweepReadsOnlyUndecidedSends(t *testing.T) {
|
||||
fake := newFakeSMTPServer(t)
|
||||
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
b.Dial = fake.dialer()
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
// One send, declined right away: decided, so it leaves the undecided index.
|
||||
@@ -686,7 +767,7 @@ func TestSweepReadsOnlyUndecidedSends(t *testing.T) {
|
||||
declinedID := out["ask"].(string)
|
||||
w := router.chose(declinedID, OptionDecline, judgedAt.Add(time.Minute))
|
||||
router.close(w)
|
||||
if _, err := Decide(sends, b.lookupAccount, b.sendActor(), w, judgedAt.Add(2*time.Minute)); err != nil {
|
||||
if _, err := Decide(sends, b.queuePublish(), w, judgedAt.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("deciding: %v", err)
|
||||
}
|
||||
|
||||
@@ -748,3 +829,195 @@ func TestAddAndRemoveUndecidedFailOnACorruptIndexRatherThanResettingIt(t *testin
|
||||
t.Fatalf("removeUndecided overwrote the corrupt index instead of leaving it: %s", sends.vals[undecidedIndexKey])
|
||||
}
|
||||
}
|
||||
|
||||
// 1194/AC3: a warrant that does not bind the request (its digest is not the ask's) is refused, as today, and
|
||||
// nothing is published on mail-sends.
|
||||
func TestADigestMismatchIsRefusedAndNothingIsQueued(t *testing.T) {
|
||||
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
queue := newMemQueue()
|
||||
b.Queue = queue.publish
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
|
||||
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
|
||||
w.AskDigest = "sha256:" + strings.Repeat("0", 64) // not the digest of the ask actually published
|
||||
router.close(w)
|
||||
|
||||
d, err := Decide(sends, b.queuePublish(), w, judgedAt.Add(2*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("deciding: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(d.Outcome, "refused: ") {
|
||||
t.Fatalf("outcome: %q, want refused: ...", d.Outcome)
|
||||
}
|
||||
if n := queue.count(); n != 0 {
|
||||
t.Fatalf("the queue received %d message(s) despite the digest mismatch", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mail_send_status answers only its caller (novox/hq ADR 0440 §7, issue 1261) ---------------------------------
|
||||
|
||||
// 1261/AC1: the caller that made a send reads its own status in full, with its account, recipients and subject
|
||||
// as before.
|
||||
func TestSendStatusAnswersTheCallersOwnSendInFull(t *testing.T) {
|
||||
b, _, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
|
||||
text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var status map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status["status"] != "waiting" || status["account"] != "mesh" || status["subject"] != sendArgs["subject"] {
|
||||
t.Fatalf("the caller's own send: %v", status)
|
||||
}
|
||||
to, _ := status["to"].([]any)
|
||||
if len(to) != 2 {
|
||||
t.Fatalf("to: %v", status["to"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1261/AC2: a caller other than the one that made the send reads status unknown and nothing else of it — no
|
||||
// account, recipients or subject, and no error either.
|
||||
func TestSendStatusAnswersUnknownAndNothingElseToAnotherCaller(t *testing.T) {
|
||||
b, _, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
|
||||
text, isErr = rt.call(SendStatusTool, "novox.someone-else", map[string]any{"id": id})
|
||||
if isErr {
|
||||
t.Fatalf("another caller's read of the send was an error: %s", text)
|
||||
}
|
||||
var status map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(status) != 1 || status["status"] != "unknown" {
|
||||
t.Fatalf("another caller's read of the send: %v, want only status unknown", status)
|
||||
}
|
||||
}
|
||||
|
||||
// 1261/AC3: an id no send is kept under answers status unknown and no error, for any caller.
|
||||
func TestSendStatusAnswersUnknownForAnUnknownIDWithoutAnError(t *testing.T) {
|
||||
b, _, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendStatusTool, "novox.triage", map[string]any{"id": "send-0000000000000000"})
|
||||
if isErr {
|
||||
t.Fatalf("an unknown id was an error: %s", text)
|
||||
}
|
||||
var status map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(status) != 1 || status["status"] != "unknown" {
|
||||
t.Fatalf("%v", status)
|
||||
}
|
||||
}
|
||||
|
||||
// 1261/AC4: every kept decision's status word is one of the fixed vocabulary (waiting, sent, failed, refused,
|
||||
// not proven, declined, expired), answered to the send's own caller.
|
||||
func TestSendStatusWordsAreTheFixedVocabularyForTheCallersOwnSend(t *testing.T) {
|
||||
sends := newMemSends()
|
||||
const caller = "novox.triage"
|
||||
cases := []struct {
|
||||
name, outcome, want string
|
||||
}{
|
||||
{"queued", "queued", "waiting"},
|
||||
{"sending", "sending", "waiting"},
|
||||
{"retrying", "retrying", "waiting"},
|
||||
{"sent", "sent", "sent"},
|
||||
{"declined", "declined", "declined"},
|
||||
{"not-proven", "not proven", "not proven"},
|
||||
{"failed", "failed: boom", "failed"},
|
||||
{"refused", "refused: nope", "refused"},
|
||||
{"ended-never-heard", "ended: never heard", "expired"},
|
||||
{"ended-other", "ended: cancelled", "expired"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
id := "send-" + c.name
|
||||
req := SendRequest{ID: id, Account: "mesh", To: []string{"ops@example.org"}, Subject: "s", Caller: caller,
|
||||
At: judgedAt, Decided: &SendDecision{At: judgedAt, Outcome: c.outcome}}
|
||||
if _, err := sends.Create(id, req); err != nil {
|
||||
t.Fatalf("%s: keeping: %v", c.name, err)
|
||||
}
|
||||
out, err := ReadSendStatus(sends, nil, nil, id, caller, judgedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
if out["status"] != c.want {
|
||||
t.Fatalf("%s: status %v, want %v", c.name, out["status"], c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1261/AC5: the seat verb mail-store.send-status answers exactly as mail_send_status, for the same caller.
|
||||
func TestSeatSendStatusAnswersExactlyAsMailSendStatus(t *testing.T) {
|
||||
b, _, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
|
||||
toolText, toolErr := rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
|
||||
seatText, seatErr := rt.call(SeatSendStatusTool, "novox.triage", map[string]any{"id": id})
|
||||
if toolErr != seatErr || toolText != seatText {
|
||||
t.Fatalf("mail_send_status: %v %s; mail-store.send-status: %v %s", toolErr, toolText, seatErr, seatText)
|
||||
}
|
||||
}
|
||||
|
||||
// 1261/AC6: the sweep settles a send left undecided no matter who asked for it — never refused by the caller
|
||||
// check mail_send_status itself applies.
|
||||
func TestSweepSettlesASendRegardlessOfCaller(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
queue := newMemQueue()
|
||||
b.Queue = queue.publish
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
|
||||
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
|
||||
router.close(w) // only the router's record holds the answer
|
||||
|
||||
// The sweep itself names no caller at all; it must still settle "novox.triage"'s send.
|
||||
b.Sweep(judgedAt.Add(2 * time.Minute))
|
||||
if n := queue.count(); n != 1 {
|
||||
t.Fatalf("the sweep did not settle the send regardless of caller: %d message(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package main
|
||||
|
||||
// mesh-mail send (novox/hq ADR 0436 §7): the sender, a process with a bus account of its own
|
||||
// (mesh-mail-send), never a seat. It alone of the module's parts reads an account's password (issue 1193);
|
||||
// the tools never send — they record the operator's Approve and queue it (issue 1194) on the work queue
|
||||
// mail-sends, which this process consumes through its one durable consumer (issue 1195). For each message
|
||||
// it verifies the warrant again and claims the send once (issue 1196), performs the one handover to the
|
||||
// server (issue 1197's classification), and records the outcome, retries a not-taken failure and emits the
|
||||
// result (issue 1198). The tell to the operator and the overdue health check are issue 1199's.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"mesh-mail/cmd/mesh-mail/internal/secret"
|
||||
)
|
||||
|
||||
// SendProcessID is this process's id, as module.json's resource "send" names it: the controller delivers
|
||||
// its own bus account as the own secret broker-<SendProcessID> (novox/hq ADR 0436 §2).
|
||||
const SendProcessID = "send"
|
||||
|
||||
// MailSendsStream is the work-queue stream mail-sends carries on the bus (novox/hq ADR 0436 §8): the
|
||||
// manifest's local name "mail-sends", capitalised with its hyphen an underscore, exactly as the controller
|
||||
// composes a stream's own bus name (mesh-controller internal/catalogue/streams.go StreamBusName,
|
||||
// strings.ToUpper(strings.ReplaceAll(local, "-", "_")); confirmed against its own fixture, mesh-controller
|
||||
// cmd/mesh-controller/process_live_test.go's mailSends, which names it "MAIL_SENDS" — review round 1,
|
||||
// finding 7).
|
||||
const MailSendsStream = "MAIL_SENDS"
|
||||
|
||||
// MessengerAsksBucket is the operator channel's own records bucket, as the controller's own fixtures name
|
||||
// it (mesh-controller cmd/mesh-controller/process_live_test.go, internal/broker/process_grants_test.go):
|
||||
// never hard-coded in mesh-sdk go/process itself, since a process may read more than one seat's records.
|
||||
const MessengerAsksBucket = "messenger_asks"
|
||||
|
||||
// routerRecordOf is the real RouterRecord (send.go), reading the router's record of one of mesh-mail's own
|
||||
// asks under conn's own account (novox/hq ADR 0436 clause 9) — the same direct-get the tools use
|
||||
// (stdio.SeatRecord), reached here as the sender's own account instead of the tool runner's. Wired into the
|
||||
// handler once the claim, the send and its outcome are all in place (issues 1197, 1198).
|
||||
func routerRecordOf(conn *process.Conn) RouterRecord {
|
||||
return func(id string) (*stdio.StateEntry, error) { return conn.Ask(MessengerAsksBucket, id) }
|
||||
}
|
||||
|
||||
// processSends adapts conn's own `sends` state (novox/hq ADR 0436 clause 7's bus.state) to SendStore
|
||||
// (send.go), the same CAS the tools use, now reached under the sender's own account. Keys is never called
|
||||
// by the claim or outcome recording (issues 1196, 1198) — only the tools' sweep calls it, over its own
|
||||
// account — so it is refused by name if ever called by mistake, rather than silently answering empty.
|
||||
type processSends struct{ state process.State }
|
||||
|
||||
func (p processSends) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
e, err := p.state.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return e.Value, e.Revision, true, nil
|
||||
}
|
||||
func (p processSends) Create(key string, value any) (uint64, error) {
|
||||
return p.state.Create(key, value)
|
||||
}
|
||||
func (p processSends) Update(key string, value any, revision uint64) (uint64, error) {
|
||||
return p.state.Update(key, value, revision)
|
||||
}
|
||||
func (p processSends) Keys() ([]string, error) {
|
||||
return nil, fmt.Errorf("the sender's own `sends` state is never listed whole; only the tools' sweep does that")
|
||||
}
|
||||
|
||||
// send is `mesh-mail send`: it reads its own bus account and the accounts setting, connects, and consumes
|
||||
// mail-sends until stopped. It refuses to start naming what is missing when the account or the accounts
|
||||
// setting cannot be read — never a password, which no error here or anywhere in this process logs
|
||||
// (novox/hq ADR 0436 §7, §12).
|
||||
func send(env Env) int {
|
||||
if env.Secrets == "" {
|
||||
logf("[mesh-mail] send was started without its secrets directory (MESH_MAIL_SECRETS)")
|
||||
return 2
|
||||
}
|
||||
if _, err := ReadAccounts(env.Accounts); err != nil {
|
||||
logf("[mesh-mail] send: %v", err)
|
||||
return 1
|
||||
}
|
||||
account, err := process.ReadAccount(secret.Path(env.Secrets, "broker-"+SendProcessID))
|
||||
if err != nil {
|
||||
logf("[mesh-mail] send: %v", err)
|
||||
return 1
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
conn, err := process.Connect(account)
|
||||
if err != nil {
|
||||
logf("[mesh-mail] send: the bus cannot be reached: %v", err)
|
||||
return 1
|
||||
}
|
||||
defer conn.Close()
|
||||
h := &senderHandler{
|
||||
Store: processSends{state: conn.State(SendsState)},
|
||||
Record: routerRecordOf(conn),
|
||||
Accounts: func() ([]Account, error) { return ReadAccounts(env.Accounts) },
|
||||
SecretsDir: env.Secrets,
|
||||
Emit: emitOf(conn),
|
||||
Tell: tellOperatorOf(conn),
|
||||
Logf: logf,
|
||||
}
|
||||
go h.tellTicker(ctx)
|
||||
logf("[mesh-mail] send is consuming %s as %s", MailSendsStream, account.User)
|
||||
if err := conn.Consume(ctx, MailSendsStream, h.handle); err != nil && ctx.Err() == nil {
|
||||
logf("[mesh-mail] send stopped: %v", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// tellOperatorOf is the real TellOperator (send.go), wrapping conn's own Call to the operator channel's
|
||||
// tell verb (novox/hq ADR 0380 §8, ADR 0436 clause 11): no lines, no subject naming anything the content
|
||||
// rule would refuse — the headline alone carries the one line.
|
||||
func tellOperatorOf(conn *process.Conn) TellOperator {
|
||||
return func(headline string) error {
|
||||
_, err := conn.Call("seat:operator-channel.tell", map[string]any{"headline": headline, "lines": []string{}, "subject": "mesh-mail"})
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// TellEvery is how often the sender looks for a send still owing its own tell (novox/hq ADR 0436 clause
|
||||
// 11: "at least every 10 minutes").
|
||||
const TellEvery = 10 * time.Minute
|
||||
|
||||
// tellTicker runs every send still owing the sender's own tell at least every TellEvery, until ctx ends
|
||||
// (novox/hq ADR 0436 clause 11, issue 1199): once right away, so a tell left over a restart is not made to
|
||||
// wait a full interval.
|
||||
func (h *senderHandler) tellTicker(ctx context.Context) {
|
||||
t := time.NewTicker(TellEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
h.tellPending()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *senderHandler) tellPending() {
|
||||
ids, err := tellPendingIDs(h.Store)
|
||||
if err != nil {
|
||||
h.logf("[mesh-mail] send: the tells-pending index cannot be read: %v", err)
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := tellSend(h.Store, h.Tell, id, h.now()); err != nil {
|
||||
h.logf("[mesh-mail] send: telling the operator about %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runSend connects as the sender's own account and consumes mail-sends until ctx ends, handing each message
|
||||
// to handle. Apart from send so a test can drive it over a bus of its own, with a handler of its own
|
||||
// (novox/hq issue 1195, AC2): on ctx ending (a signal), Consume stops pulling and returns without
|
||||
// acknowledging a message still in handle's hands — only handle decides a message's fate (process/queue.go).
|
||||
func runSend(ctx context.Context, account process.Account, handle func(*process.Message)) error {
|
||||
conn, err := process.Connect(account)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the bus cannot be reached: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
return conn.Consume(ctx, MailSendsStream, handle)
|
||||
}
|
||||
|
||||
// emitOf is the real Emit (send.go), wrapping conn's own Emit with the given id as both x-event-id and the
|
||||
// bus's own dedup id (novox/hq ADR 0436 clauses 4, 11).
|
||||
func emitOf(conn *process.Conn) Emit {
|
||||
return func(eventType, id string, body any) error {
|
||||
return conn.Emit(eventType, body, stdio.EmitOptions{MessageID: id})
|
||||
}
|
||||
}
|
||||
|
||||
// senderHandler is everything handleQueuedSend needs: the `sends` state and the router's record, both
|
||||
// reached under the sender's own account, the accounts setting (to find the server to connect to) and the
|
||||
// secrets directory (to read the one password this process holds), and how it sends and emits — real
|
||||
// unless a test gives its own (novox/hq ADR 0436 §7, issues 1196 to 1198).
|
||||
type senderHandler struct {
|
||||
Store SendStore
|
||||
Record RouterRecord
|
||||
Accounts func() ([]Account, error)
|
||||
SecretsDir string
|
||||
Dial SMTPDialer
|
||||
Emit Emit
|
||||
Tell TellOperator
|
||||
Now func() time.Time
|
||||
Logf func(format string, a ...any)
|
||||
}
|
||||
|
||||
func (h *senderHandler) now() time.Time {
|
||||
if h.Now != nil {
|
||||
return h.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (h *senderHandler) logf(format string, a ...any) {
|
||||
if h.Logf != nil {
|
||||
h.Logf(format, a...)
|
||||
}
|
||||
}
|
||||
|
||||
// handle is the handler mesh-mail send's Consume hands every message to mail-sends (novox/hq ADR 0436
|
||||
// clauses 9, 10, 11): the warrant verified again and the send claimed (claimQueuedSend, send.go), the one
|
||||
// handover to the server attempted, and the outcome recorded, retried or emitted (recordOutcome, send.go) —
|
||||
// then, and only then, the message is acknowledged, asked redelivered, or terminated, exactly as that
|
||||
// outcome says.
|
||||
func (h *senderHandler) handle(msg *process.Message) {
|
||||
var q QueuedSend
|
||||
if err := json.Unmarshal(msg.Body, &q); err != nil {
|
||||
h.logf("[mesh-mail] send: a queued message on %s could not be read: %v", msg.Subject, err)
|
||||
if err := msg.Term(); err != nil {
|
||||
h.logf("[mesh-mail] send: terminating %s: %v", msg.Subject, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
req, claimed, rev, outcome, err := claimQueuedSend(h.Store, h.Record, h.Emit, q.Send, h.now())
|
||||
if err != nil {
|
||||
// Logged, never returned on alone: an index-bookkeeping error (novox/hq review round 1 finding 3)
|
||||
// can stand beside a real decision (claimRefused, claimNotProven) that the switch below still
|
||||
// must act on; a fatal read or claim failure, with no decision made, always reads claimLostRace,
|
||||
// which the switch already leaves to the bus.
|
||||
h.logf("[mesh-mail] send: claiming %s: %v", q.Send, err)
|
||||
}
|
||||
switch outcome {
|
||||
case claimLostRace:
|
||||
return // another delivery of the same message already owns it.
|
||||
case claimRefused, claimNotProven, claimAlreadyFinished:
|
||||
// claimAlreadyFinished: a repeat of a message whose send already finished (the operator was
|
||||
// already told, or not, exactly once) — terminated, and nothing else touched (novox/hq review,
|
||||
// operator-approved change to clause 10).
|
||||
if err := msg.Term(); err != nil {
|
||||
h.logf("[mesh-mail] send: terminating %s: %v", q.Send, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
sendErr := h.attempt(req)
|
||||
_, action, delay, err := recordOutcome(h.Store, h.Emit, req, claimed, rev, sendErr, h.now())
|
||||
if err != nil {
|
||||
h.logf("[mesh-mail] send: recording the outcome of %s: %v", q.Send, err)
|
||||
}
|
||||
switch action {
|
||||
case actionAck:
|
||||
if err := msg.Ack(); err != nil {
|
||||
h.logf("[mesh-mail] send: acknowledging %s: %v", q.Send, err)
|
||||
}
|
||||
case actionRetry:
|
||||
if err := msg.NakWithDelay(delay); err != nil {
|
||||
h.logf("[mesh-mail] send: asking %s redelivered: %v", q.Send, err)
|
||||
}
|
||||
case actionTerm:
|
||||
if err := msg.Term(); err != nil {
|
||||
h.logf("[mesh-mail] send: terminating %s: %v", q.Send, err)
|
||||
}
|
||||
case actionLeave:
|
||||
// The outcome's own write could not be kept, even retried on a changed revision (novox/hq review
|
||||
// round 1 finding 5): nothing was emitted above, and nothing is done to the message either — left
|
||||
// entirely to the bus, so a later redelivery finds the record still at its previous outcome.
|
||||
}
|
||||
}
|
||||
|
||||
// attempt is the one handover to req.Account's outgoing server (novox/hq ADR 0436 clause 10): it reads the
|
||||
// accounts setting and the account's own password fresh, never from the queue's message, and every way it
|
||||
// cannot even be attempted (the account vanished, its password not given, the setting unreadable) is a
|
||||
// *SendError classified NotTaken, exactly as SendMail's own would be — safe to retry.
|
||||
func (h *senderHandler) attempt(req SendRequest) error {
|
||||
accounts, err := h.Accounts()
|
||||
if err != nil {
|
||||
return &SendError{Outcome: NotTaken, Err: fmt.Errorf("the accounts setting cannot be read: %w", err)}
|
||||
}
|
||||
acc, found := accountByName(accounts, req.Account)
|
||||
if !found {
|
||||
return &SendError{Outcome: NotTaken, Err: fmt.Errorf("the account %s no longer exists", req.Account)}
|
||||
}
|
||||
pw, given, err := secret.Read(h.SecretsDir, acc.Secret())
|
||||
if err != nil {
|
||||
return &SendError{Outcome: NotTaken, Err: fmt.Errorf("the account %s's password cannot be read: %w", acc.Name, err)}
|
||||
}
|
||||
if !given {
|
||||
return &SendError{Outcome: NotTaken, Err: fmt.Errorf("the account %s has no password yet; the operator gives it at the desk", acc.Name)}
|
||||
}
|
||||
dial := h.Dial
|
||||
if dial == nil {
|
||||
dial = DialSMTP
|
||||
}
|
||||
return SendMail(dial, acc, pw, req.To, req.Subject, req.Body, h.now(), req.ID)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// mesh-mail send over a real bus (novox/hq ADR 0436 §7, issue 1195 AC2): a JetStream server of the test's
|
||||
// own, nats-server v2.11.17, copied from modules/mesh-delivery/cmd/mesh-delivery/bus_test.go:52-58
|
||||
// (newTestBus) — the SDK's own testbus is internal, so this module keeps its own copy. Compiled by the
|
||||
// merge gate's `go vet -tags integration`; never run on this machine (the context file's hard rule).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// mailSendApprovedSubject is the real wire subject the controller composes for mesh-mail's own stream
|
||||
// mail-sends' one event, mail.send.approved: "mesh.stream." + module + ".event." + event (mesh-controller
|
||||
// internal/broker/modstreams.go ModuleStreamSubject, moduleStreamPrefix). The sender's own Consume binds
|
||||
// by stream and consumer name alone (mesh-sdk go/process queue.go), so this subject matters only to how a
|
||||
// test builds the stream and publishes to it — it must match what the controller actually composes, or an
|
||||
// integration test proves nothing about the real path (novox/hq review round 1, finding 7).
|
||||
const mailSendApprovedSubject = "mesh.stream." + Module + ".event." + MailSendApproved
|
||||
|
||||
// mailSendsStreamConfig is the work-queue stream mail-sends, composed exactly as the controller would
|
||||
// (mesh-controller internal/catalogue/streams.go StreamDeclaration.Stream and
|
||||
// internal/broker/modstreams.go ModuleStream.Stream): retention work-queue, discard new, module.json's
|
||||
// max-age-days 1 and max-mib 64, and its "duplicates": 24 — in hours (novox/hq ADR 0436 clause 6) — which
|
||||
// the controller turns into DuplicatesSeconds = Duplicates * 60 * 60, 24 hours here.
|
||||
func mailSendsStreamConfig() jetstream.StreamConfig {
|
||||
return jetstream.StreamConfig{
|
||||
Name: MailSendsStream,
|
||||
Subjects: []string{mailSendApprovedSubject},
|
||||
Retention: jetstream.WorkQueuePolicy,
|
||||
Discard: jetstream.DiscardNew,
|
||||
MaxAge: 24 * time.Hour,
|
||||
MaxBytes: 64 << 20,
|
||||
Duplicates: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// mailSendsConsumerConfig is the one durable consumer the controller composes for a process's bus.queues
|
||||
// (mesh-controller internal/broker/modstreams.go QueueConsumer): AckWaitSeconds 30, MaxDeliver from the
|
||||
// stream's own max-deliveries (module.json: 5, MostAttempts here).
|
||||
func mailSendsConsumerConfig(durable string) jetstream.ConsumerConfig {
|
||||
return jetstream.ConsumerConfig{Durable: durable, AckPolicy: jetstream.AckExplicitPolicy, AckWait: 30 * time.Second, MaxDeliver: MostAttempts}
|
||||
}
|
||||
|
||||
// newTestBus starts a JetStream-enabled nats-server for the test alone, on a random port with its own temp
|
||||
// store (modules/mesh-delivery/cmd/mesh-delivery/bus_test.go:52-58).
|
||||
func newTestBus(t *testing.T) string {
|
||||
t.Helper()
|
||||
opts := &server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(),
|
||||
NoLog: true, NoSigs: true}
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("a bus for this test could not be made: %v", err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(30 * time.Second) {
|
||||
s.Shutdown()
|
||||
t.Fatal("a bus for this test did not come up within 30s")
|
||||
}
|
||||
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
|
||||
return s.ClientURL()
|
||||
}
|
||||
|
||||
// 1195/AC2: given the mail-sends work-queue stream and its consumer <machine>_mesh-mail_send, when the
|
||||
// process runs with a handler and a message is published, then the handler receives it; when the process
|
||||
// gets SIGTERM (here, its context cancelled, the same signal main.go's send() reacts to) it stops without
|
||||
// acknowledging a message in hand.
|
||||
func TestSendConsumesMailSendsAndStopsCleanlyOnSignal(t *testing.T) {
|
||||
url := newTestBus(t)
|
||||
ctx := context.Background()
|
||||
|
||||
nc, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer nc.Close()
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.CreateStream(ctx, mailSendsStreamConfig()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
account := process.Account{URL: url, Node: "g14", Module: Module, User: "g14." + Module + "." + SendProcessID, Password: "pw"}
|
||||
if _, err := js.CreateConsumer(ctx, MailSendsStream, mailSendsConsumerConfig(account.QueueConsumerName())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
received := make(chan *process.Message, 1)
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- runSend(runCtx, account, func(m *process.Message) { received <- m }) }()
|
||||
|
||||
body := []byte(`{"send":"send-1","account":"mesh","ask":"send-1"}`)
|
||||
// Published twice under the same message id (the send id, as the tools' own publish path always uses,
|
||||
// novox/hq ADR 0436 clause 8): the stream's own 24-hour duplicate window keeps it one message, proving
|
||||
// the dedup this test's stream config actually carries (novox/hq review round 1, finding 7).
|
||||
if _, err := js.Publish(ctx, mailSendApprovedSubject, body, jetstream.WithMsgID("send-1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.Publish(ctx, mailSendApprovedSubject, body, jetstream.WithMsgID("send-1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case msg := <-received:
|
||||
if msg.Subject != mailSendApprovedSubject {
|
||||
t.Fatalf("received on %q, want %q", msg.Subject, mailSendApprovedSubject)
|
||||
}
|
||||
// Left exactly here, never acknowledged: the handler's own fate-deciding (Ack, NakWithDelay, Term) is
|
||||
// issues 1196-1198's, not this skeleton's.
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the handler never received the published message")
|
||||
}
|
||||
select {
|
||||
case <-received:
|
||||
t.Fatal("the duplicate publish under the same message id was delivered a second time")
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
// The duplicate was dropped by the stream's own window, as it should be.
|
||||
}
|
||||
|
||||
cancel() // the signal send() itself reacts to (SIGTERM, or ^C)
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("runSend returned nil, want ctx.Err() once its context was cancelled")
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("runSend did not stop once its context was cancelled")
|
||||
}
|
||||
|
||||
// The message was never acknowledged: it is still pending (or ack-pending) on the same durable consumer,
|
||||
// read fresh over a new connection.
|
||||
nc2, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer nc2.Close()
|
||||
js2, err := jetstream.New(nc2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cons, err := js2.Consumer(ctx, MailSendsStream, account.QueueConsumerName())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := cons.Info(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.NumPending == 0 && info.NumAckPending == 0 {
|
||||
t.Fatal("the message was acknowledged despite the process stopping mid-handle")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
// mesh-mail send (novox/hq ADR 0436 §7, issue 1195): refuses to start naming what is missing, never
|
||||
// attempting to reach a bus when it cannot even read its own account or the accounts setting.
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1195/AC1: given `mesh-mail send` with MESH_MAIL_SECRETS holding broker-send.secret in the controller's
|
||||
// form, when it starts, then it reads the account and the accounts file, and refuses to start naming what
|
||||
// is missing when either is absent.
|
||||
func TestSendRefusesToStartNamingWhatIsMissing(t *testing.T) {
|
||||
validAccount := `{"url": "nats://127.0.0.1:4222", "node": "g14", "module": "mesh-mail", ` +
|
||||
`"user": "g14.mesh-mail.send", "password": "pw"}`
|
||||
|
||||
t.Run("no secrets directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := Env{Accounts: filepath.Join(dir, "accounts.json")}
|
||||
writeFile(t, env.Accounts, stageOne)
|
||||
if code := send(env); code == 0 {
|
||||
t.Fatal("send started with no secrets directory at all")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no accounts file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Secrets: dir}
|
||||
writeFile(t, filepath.Join(dir, "broker-send.secret"), validAccount)
|
||||
if code := send(env); code == 0 {
|
||||
t.Fatal("send started with no accounts setting")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accounts file is not the module's", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Secrets: dir}
|
||||
writeFile(t, env.Accounts, `not json`)
|
||||
writeFile(t, filepath.Join(dir, "broker-send.secret"), validAccount)
|
||||
if code := send(env); code == 0 {
|
||||
t.Fatal("send started with an accounts setting it cannot read")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no own account delivered yet", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Secrets: dir}
|
||||
writeFile(t, env.Accounts, stageOne)
|
||||
// broker-send.secret is not written at all.
|
||||
if code := send(env); code == 0 {
|
||||
t.Fatal("send started with no own account delivered")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("own account missing a field", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Secrets: dir}
|
||||
writeFile(t, env.Accounts, stageOne)
|
||||
writeFile(t, filepath.Join(dir, "broker-send.secret"), `{"url": "nats://127.0.0.1:4222", "node": "g14", "module": "mesh-mail"}`)
|
||||
code := send(env)
|
||||
if code == 0 {
|
||||
t.Fatal("send started with an own account missing its user and password")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The account file's path is exactly what secret.Path gives for the member "broker-<process id>", inside
|
||||
// the directory MESH_MAIL_SECRETS already names — no secret of its own needs declaring in the manifest for
|
||||
// it (novox/hq ADR 0436 §2: the controller delivers it automatically for every process with a bus).
|
||||
func TestSendProcessIDNamesItsOwnAccountFile(t *testing.T) {
|
||||
if SendProcessID != "send" {
|
||||
t.Fatalf("SendProcessID: %q; module.json's resource id is \"send\"", SendProcessID)
|
||||
}
|
||||
// mesh-controller's own StreamBusName: "mail-sends" capitalised, its hyphen an underscore (review round
|
||||
// 1, finding 7 — confirmed against cmd/mesh-controller/process_live_test.go's own fixture).
|
||||
if MailSendsStream != "MAIL_SENDS" {
|
||||
t.Fatalf("MailSendsStream: %q, want MAIL_SENDS", MailSendsStream)
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -55,10 +57,63 @@ func DialSMTP(server Server) (*smtp.Client, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// SendMail sends one message through the account's outgoing server (novox/hq ADR 0361 §1, §2): signs in with the
|
||||
// password given, states every recipient, and writes the message once. Never logs the password or the body; an
|
||||
// error names the server and what it said, never either.
|
||||
func SendMail(dial SMTPDialer, a Account, password string, to []string, subject, body string, now time.Time) error {
|
||||
// SendOutcome classifies how a send attempt ended (novox/hq ADR 0436 clause 10), so the sender knows
|
||||
// whether a retry is safe, certainly wrong, or neither:
|
||||
//
|
||||
// - NotTaken: no connection, the sign-in refused, or a 4xx reply to MAIL, RCPT or DATA, or to the end of
|
||||
// data — the server provably never took the mail; retrying is safe.
|
||||
// - Refused: a 5xx reply before or at the end of data — the server provably refused the mail; never
|
||||
// retried.
|
||||
// - NotProven: the connection was lost after the data was written, with no reply at all — the mail may or
|
||||
// may not have gone out; never retried automatically either way.
|
||||
type SendOutcome int
|
||||
|
||||
const (
|
||||
NotTaken SendOutcome = iota
|
||||
Refused
|
||||
NotProven
|
||||
)
|
||||
|
||||
func (o SendOutcome) String() string {
|
||||
switch o {
|
||||
case Refused:
|
||||
return "refused"
|
||||
case NotProven:
|
||||
return "not proven"
|
||||
default:
|
||||
return "not taken"
|
||||
}
|
||||
}
|
||||
|
||||
// SendError classifies a SendMail failure (novox/hq ADR 0436 clause 10): Outcome says which, and the error
|
||||
// itself (Unwrap) carries the server's own words where there were any, never the password or the body.
|
||||
type SendError struct {
|
||||
Outcome SendOutcome
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *SendError) Error() string { return e.Err.Error() }
|
||||
func (e *SendError) Unwrap() error { return e.Err }
|
||||
|
||||
// classifyReply is NotTaken for a 4xx reply, Refused for a 5xx, and NotTaken for anything else (no
|
||||
// connection, or the connection lost before a reply was read at all) — the safe default everywhere but the
|
||||
// one case clause 10 names its own outcome for: the connection lost after the data was written, which the
|
||||
// caller classifies as NotProven itself, never through this.
|
||||
func classifyReply(err error) SendOutcome {
|
||||
var proto *textproto.Error
|
||||
if errors.As(err, &proto) && proto.Code >= 500 {
|
||||
return Refused
|
||||
}
|
||||
return NotTaken
|
||||
}
|
||||
|
||||
// SendMail sends one message through the account's outgoing server (novox/hq ADR 0361 §1, §2; ADR 0436
|
||||
// clause 10): signs in with the password given, states every recipient, and writes the message once, its
|
||||
// Message-ID naming sendID so the mail is never sent twice within the day (clause 10's own guarantee, kept
|
||||
// by the sender's compare-and-set claim and the bus's duplicate window on the id alone — this is the
|
||||
// mail's own proof of it). Never logs the password or the body; an error names the server and what it
|
||||
// said, never either, and is always a *SendError (classifyReply, or NotProven below).
|
||||
func SendMail(dial SMTPDialer, a Account, password string, to []string, subject, body string, now time.Time, sendID string) error {
|
||||
if a.Outgoing == nil {
|
||||
return fmt.Errorf("the account %s has no outgoing server", a.Name)
|
||||
}
|
||||
@@ -76,44 +131,63 @@ func SendMail(dial SMTPDialer, a Account, password string, to []string, subject,
|
||||
}
|
||||
client, err := dial(*a.Outgoing)
|
||||
if err != nil {
|
||||
return err
|
||||
return &SendError{Outcome: NotTaken, Err: err}
|
||||
}
|
||||
defer client.Close()
|
||||
from := a.SignInAs()
|
||||
auth := smtp.PlainAuth("", from, password, a.Outgoing.Host)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("signing in to %s failed: %w", a.Outgoing.Address(), err)
|
||||
// A refused sign-in is its own case, never classified by a reply code: the server never took the
|
||||
// mail either way (novox/hq ADR 0436 clause 10).
|
||||
return &SendError{Outcome: NotTaken, Err: fmt.Errorf("signing in to %s failed: %w", a.Outgoing.Address(), err)}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return fmt.Errorf("the outgoing server refused the sender: %w", err)
|
||||
return &SendError{Outcome: classifyReply(err), Err: fmt.Errorf("the outgoing server refused the sender: %w", err)}
|
||||
}
|
||||
for _, rcpt := range rcpts {
|
||||
if err := client.Rcpt(rcpt); err != nil {
|
||||
return fmt.Errorf("the outgoing server refused the recipient %s: %w", rcpt, err)
|
||||
return &SendError{Outcome: classifyReply(err), Err: fmt.Errorf("the outgoing server refused the recipient %s: %w", rcpt, err)}
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("the outgoing server refused the message: %w", err)
|
||||
return &SendError{Outcome: classifyReply(err), Err: fmt.Errorf("the outgoing server refused the message: %w", err)}
|
||||
}
|
||||
if _, err := w.Write(composeMessage(from, headers, subject, body, now)); err != nil {
|
||||
return fmt.Errorf("the message could not be written: %w", err)
|
||||
if _, err := w.Write(composeMessage(from, headers, subject, body, now, sendID)); err != nil {
|
||||
// The data was not written whole: never "after the data was written" (clause 10's not-proven case),
|
||||
// so this is not taken, not not-proven — nothing coherent reached the server.
|
||||
return &SendError{Outcome: NotTaken, Err: fmt.Errorf("the message could not be written: %w", err)}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("the outgoing server did not accept the message: %w", err)
|
||||
// Close is what sends the terminating "." and reads the server's one reply to the whole message
|
||||
// (SMTP's "end of data", clause 10): a real reply here classifies exactly as MAIL or RCPT's does; no
|
||||
// reply at all (the connection lost after the data was written) is not proven — never retried
|
||||
// automatically either way.
|
||||
outcome := NotProven
|
||||
var proto *textproto.Error
|
||||
if errors.As(err, &proto) {
|
||||
outcome = classifyReply(err)
|
||||
}
|
||||
return &SendError{Outcome: outcome, Err: fmt.Errorf("the outgoing server did not accept the message: %w", err)}
|
||||
}
|
||||
return client.Quit()
|
||||
// The message is already accepted (Close's 250 reply); Quit is a courtesy goodbye, and its own failure
|
||||
// here never turns an accepted send into a failed one.
|
||||
_ = client.Quit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// composeMessage is a plain-text message, its lines CRLF-terminated as SMTP takes them. to is the recipients
|
||||
// already formatted (mail.Address.String()), the same ones Rcpt was given, never the raw strings a caller gave.
|
||||
func composeMessage(from string, to []string, subject, body string, now time.Time) []byte {
|
||||
// Its Message-ID names sendID (novox/hq ADR 0436 clause 10), so the mail carries the proof of which send it
|
||||
// is, at <sendID>@<the account's own domain, from the From address>.
|
||||
func composeMessage(from string, to []string, subject, body string, now time.Time, sendID string) []byte {
|
||||
var b strings.Builder
|
||||
header := func(k, v string) { b.WriteString(k + ": " + v + "\r\n") }
|
||||
header("From", from)
|
||||
header("To", strings.Join(to, ", "))
|
||||
header("Subject", encodeHeader(subject))
|
||||
header("Date", now.Format(time.RFC1123Z))
|
||||
header("Message-Id", "<"+sendID+"@"+messageIDDomain(from)+">")
|
||||
header("MIME-Version", "1.0")
|
||||
header("Content-Type", "text/plain; charset=utf-8")
|
||||
header("Content-Transfer-Encoding", "8bit")
|
||||
@@ -122,6 +196,20 @@ func composeMessage(from string, to []string, subject, body string, now time.Tim
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// messageIDDomain is the domain a Message-ID is minted under: the part of from after its @, as a bare
|
||||
// address or parsed out of "Name <addr>"; "localhost" when from carries none (never empty, which is not a
|
||||
// valid Message-ID).
|
||||
func messageIDDomain(from string) string {
|
||||
addr := from
|
||||
if parsed, err := mail.ParseAddress(from); err == nil {
|
||||
addr = parsed.Address
|
||||
}
|
||||
if _, domain, found := strings.Cut(addr, "@"); found && domain != "" {
|
||||
return domain
|
||||
}
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
// encodeHeader word-encodes a header's value when it holds more than ASCII; a header a phone never needs to show.
|
||||
func encodeHeader(s string) string {
|
||||
for _, r := range s {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package main
|
||||
|
||||
// SendMail classifies every failure (novox/hq ADR 0436 clause 10, issue 1197): not taken (no connection, a
|
||||
// refused sign-in, or a 4xx reply to MAIL, RCPT or DATA or to the end of data), refused (a 5xx before or at
|
||||
// the end of data), or not proven (the connection lost after the data was written, before any reply). Its
|
||||
// Message-ID names the send id.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// step is one scripted SMTP reply, or a drop (the connection closed instead of a reply).
|
||||
type step struct {
|
||||
drop bool
|
||||
code int
|
||||
msg string
|
||||
}
|
||||
|
||||
func reply(code int, msg string) step { return step{code: code, msg: msg} }
|
||||
func drop() step { return step{drop: true} }
|
||||
|
||||
// scriptedSMTPServer answers EHLO, AUTH, MAIL, RCPT, DATA's own 354 and the end-of-data reply each with a
|
||||
// step a test sets, defaulting to the ordinary success sequence — so a test overrides exactly the one stage
|
||||
// its case is about.
|
||||
type scriptedSMTPServer struct {
|
||||
ln net.Listener
|
||||
ehlo, auth, mail, rcpt, dataOpen, dataEnd step
|
||||
received int32 // atomic: how many full DATA bodies this server has read
|
||||
}
|
||||
|
||||
func newScriptedSMTPServer(t *testing.T) *scriptedSMTPServer {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &scriptedSMTPServer{ln: ln, ehlo: reply(250, "fake.local"), auth: reply(235, "authenticated"),
|
||||
mail: reply(250, "OK"), rcpt: reply(250, "OK"), dataOpen: reply(354, "go ahead"), dataEnd: reply(250, "queued")}
|
||||
go s.serve()
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *scriptedSMTPServer) addr() string { return s.ln.Addr().String() }
|
||||
|
||||
// receivedCount is how many full DATA bodies this server has read, for a test to prove nothing was
|
||||
// received at all (novox/hq review round 1 finding 6).
|
||||
func (s *scriptedSMTPServer) receivedCount() int { return int(atomic.LoadInt32(&s.received)) }
|
||||
|
||||
func (s *scriptedSMTPServer) dialer() SMTPDialer {
|
||||
addr := s.addr()
|
||||
return func(Server) (*smtp.Client, error) { return smtp.Dial(addr) }
|
||||
}
|
||||
|
||||
func (s *scriptedSMTPServer) send(conn net.Conn, st step) bool {
|
||||
if st.drop {
|
||||
_ = conn.Close()
|
||||
return false
|
||||
}
|
||||
fmt.Fprintf(conn, "%d %s\r\n", st.code, st.msg)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *scriptedSMTPServer) serve() {
|
||||
for {
|
||||
conn, err := s.ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go s.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scriptedSMTPServer) handle(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
fmt.Fprintf(conn, "220 fake.local ESMTP\r\n")
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
upper := strings.ToUpper(strings.TrimRight(line, "\r\n"))
|
||||
switch {
|
||||
case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"):
|
||||
if !s.send(conn, s.ehlo) {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(upper, "AUTH"):
|
||||
if !s.send(conn, s.auth) {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(upper, "MAIL"):
|
||||
if !s.send(conn, s.mail) {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(upper, "RCPT"):
|
||||
if !s.send(conn, s.rcpt) {
|
||||
return
|
||||
}
|
||||
case upper == "DATA":
|
||||
if !s.send(conn, s.dataOpen) {
|
||||
return
|
||||
}
|
||||
if s.dataOpen.code >= 400 {
|
||||
continue // not entering data mode; the client ends the transaction itself
|
||||
}
|
||||
for {
|
||||
dl, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if dl == ".\r\n" || dl == ".\n" {
|
||||
break
|
||||
}
|
||||
}
|
||||
atomic.AddInt32(&s.received, 1) // a full message body was read, whatever dataEnd then says
|
||||
s.send(conn, s.dataEnd) // a drop here is "the connection lost after the data was written"
|
||||
return
|
||||
case upper == "QUIT":
|
||||
fmt.Fprintf(conn, "221 bye\r\n")
|
||||
return
|
||||
default:
|
||||
fmt.Fprintf(conn, "500 unrecognized\r\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// classifyTestAccount is an account whose outgoing server is the given address: Host is its loopback
|
||||
// literal, which PlainAuth itself accepts without TLS (its own isLocalhost check).
|
||||
func classifyTestAccount(addr string) Account {
|
||||
host, portStr, _ := net.SplitHostPort(addr)
|
||||
var port int
|
||||
_, _ = fmt.Sscanf(portStr, "%d", &port)
|
||||
return Account{Name: "mesh", Address: "jschoubben@novox.be", Outgoing: &Server{Host: host, Port: port, Security: "tls"}}
|
||||
}
|
||||
|
||||
func outcomeOf(t *testing.T, err error) SendOutcome {
|
||||
t.Helper()
|
||||
var se *SendError
|
||||
if !errors.As(err, &se) {
|
||||
t.Fatalf("SendMail's error is not a *SendError: %v (%T)", err, err)
|
||||
}
|
||||
return se.Outcome
|
||||
}
|
||||
|
||||
// 1197/AC1: given no connection, a refused sign-in, or a 4xx reply to MAIL, RCPT or DATA or to the end of
|
||||
// data, when SendMail runs, then it says not taken with the server's words.
|
||||
func TestSendMailSaysNotTaken(t *testing.T) {
|
||||
t.Run("no connection", func(t *testing.T) {
|
||||
s := newScriptedSMTPServer(t)
|
||||
addr := s.addr()
|
||||
s.ln.Close() // nothing listens by the time SendMail dials
|
||||
acc := classifyTestAccount(addr)
|
||||
dial := func(Server) (*smtp.Client, error) { return smtp.Dial(addr) }
|
||||
err := SendMail(dial, acc, "pw", []string{"a@b.c"}, "s", "body", judgedAt, "send-1")
|
||||
if err == nil || outcomeOf(t, err) != NotTaken {
|
||||
t.Fatalf("err %v, want NotTaken", err)
|
||||
}
|
||||
})
|
||||
t.Run("a refused sign-in", func(t *testing.T) {
|
||||
s := newScriptedSMTPServer(t)
|
||||
s.auth = reply(535, "authentication failed")
|
||||
acc := classifyTestAccount(s.addr())
|
||||
err := SendMail(s.dialer(), acc, "pw", []string{"a@b.c"}, "s", "body", judgedAt, "send-1")
|
||||
if err == nil || outcomeOf(t, err) != NotTaken || !strings.Contains(err.Error(), "signing in") {
|
||||
t.Fatalf("err %v, want NotTaken naming the sign-in", err)
|
||||
}
|
||||
})
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
set func(*scriptedSMTPServer)
|
||||
}{
|
||||
{"a 4xx to MAIL", func(s *scriptedSMTPServer) { s.mail = reply(451, "try again later") }},
|
||||
{"a 4xx to RCPT", func(s *scriptedSMTPServer) { s.rcpt = reply(450, "mailbox busy") }},
|
||||
{"a 4xx to DATA", func(s *scriptedSMTPServer) { s.dataOpen = reply(452, "insufficient storage") }},
|
||||
{"a 4xx at the end of data", func(s *scriptedSMTPServer) { s.dataEnd = reply(451, "local error") }},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s := newScriptedSMTPServer(t)
|
||||
c.set(s)
|
||||
acc := classifyTestAccount(s.addr())
|
||||
err := SendMail(s.dialer(), acc, "pw", []string{"a@b.c"}, "s", "body", judgedAt, "send-1")
|
||||
if err == nil || outcomeOf(t, err) != NotTaken {
|
||||
t.Fatalf("err %v, want NotTaken", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 1197/AC2: given a 5xx reply before or at the end of data, then it says refused with the server's words.
|
||||
func TestSendMailSaysRefused(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
set func(*scriptedSMTPServer)
|
||||
}{
|
||||
{"a 5xx to MAIL", func(s *scriptedSMTPServer) { s.mail = reply(550, "no such sender") }},
|
||||
{"a 5xx to RCPT", func(s *scriptedSMTPServer) { s.rcpt = reply(550, "no such recipient") }},
|
||||
{"a 5xx to DATA", func(s *scriptedSMTPServer) { s.dataOpen = reply(554, "transaction failed") }},
|
||||
{"a 5xx at the end of data", func(s *scriptedSMTPServer) { s.dataEnd = reply(552, "quota exceeded") }},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s := newScriptedSMTPServer(t)
|
||||
c.set(s)
|
||||
acc := classifyTestAccount(s.addr())
|
||||
err := SendMail(s.dialer(), acc, "pw", []string{"a@b.c"}, "s", "body", judgedAt, "send-1")
|
||||
if err == nil || outcomeOf(t, err) != Refused {
|
||||
t.Fatalf("err %v, want Refused", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 1197/AC3: given the connection lost after the data was written and before any reply, then it says not
|
||||
// proven.
|
||||
func TestSendMailSaysNotProvenWhenTheConnectionIsLostAfterTheDataWasWritten(t *testing.T) {
|
||||
s := newScriptedSMTPServer(t)
|
||||
s.dataEnd = drop()
|
||||
acc := classifyTestAccount(s.addr())
|
||||
err := SendMail(s.dialer(), acc, "pw", []string{"a@b.c"}, "s", "body", judgedAt, "send-1")
|
||||
if err == nil || outcomeOf(t, err) != NotProven {
|
||||
t.Fatalf("err %v, want NotProven", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1197/AC4: given a send id, when the message is composed, then it carries a Message-ID naming the send id
|
||||
// (<send id>@<the account's domain>). Proved both in isolation (composeMessage) and over the wire
|
||||
// (SendMail, with the existing fakeSMTPServer, which keeps the body it received).
|
||||
func TestComposeMessageCarriesTheSendIDInItsMessageID(t *testing.T) {
|
||||
raw := composeMessage("jschoubben@novox.be", []string{"a@b.c"}, "s", "body", judgedAt, "send-abc123")
|
||||
if !strings.Contains(string(raw), "Message-Id: <send-abc123@novox.be>") {
|
||||
t.Fatalf("the message does not carry the send id's Message-ID:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// composeMessage falls back to "localhost" when From carries no domain at all (never an empty or
|
||||
// malformed Message-ID).
|
||||
func TestComposeMessageFallsBackToLocalhostWithNoDomain(t *testing.T) {
|
||||
raw := composeMessage("postmaster", nil, "s", "body", judgedAt, "send-1")
|
||||
if !strings.Contains(string(raw), "Message-Id: <send-1@localhost>") {
|
||||
t.Fatalf("message: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMailCarriesTheMessageIDOverTheWire(t *testing.T) {
|
||||
fake := newFakeSMTPServer(t)
|
||||
acc := classifyTestAccount(fake.addr())
|
||||
if err := SendMail(fake.dialer(), acc, "pw", []string{"a@b.c"}, "s", "body", judgedAt, "send-xyz"); err != nil {
|
||||
t.Fatalf("SendMail: %v", err)
|
||||
}
|
||||
got := fake.last()
|
||||
if !strings.Contains(got.data, "Message-Id: <send-xyz@novox.be>") {
|
||||
t.Fatalf("the message sent does not carry the send id's Message-ID:\n%s", got.data)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -90,6 +92,73 @@ type Query struct {
|
||||
Accounts []string
|
||||
Folder string
|
||||
Limit int
|
||||
|
||||
// From (issue 1228, AC2, AC3, AC6's cc part; issue 1230, AC11's "several domains") is a list of addresses or
|
||||
// domains, case-insensitive, OR'd together: a message matches when at least one entry matches (a domain, no
|
||||
// "@", matching every address of it). To and Cc (issue 1228, AC4, AC6's cc part) are each a single address or
|
||||
// domain, the same way; no criterion asks for more than one. Empty/nil means no filter on that field.
|
||||
From []string
|
||||
To string
|
||||
Cc string
|
||||
|
||||
// OnOrAfter and Before (issue 1228, AC2-AC4) bound Date (the Date header, imap.go:483-485): on-or-after is
|
||||
// inclusive, before is exclusive. A zero time means no bound.
|
||||
OnOrAfter time.Time
|
||||
Before time.Time
|
||||
|
||||
// SubjectWords and BodyWords (issue 1229, AC13) are each a whole word, matched case-insensitively against
|
||||
// Subject or Body respectively, and only that field (a word with digits and a dash, such as "4711-882", is
|
||||
// matched whole, never by its separate parts, AC12). By default every word of the list must be found ("all");
|
||||
// the *WordsAny flag switches that list to "any of them" (AC15). Empty means no filter.
|
||||
SubjectWords []string
|
||||
SubjectWordsAny bool
|
||||
BodyWords []string
|
||||
BodyWordsAny bool
|
||||
|
||||
// Words (issue 1229, AC5, AC6's words part, AC12, AC15; review round 1 finding 3) is the same whole-word,
|
||||
// case-insensitive match as SubjectWords/BodyWords, but each word may be found in either field, Subject or
|
||||
// Body: "the word in subject or body", as the acceptance criteria ask it, where SubjectWords/BodyWords alone
|
||||
// would wrongly need it in one specific field. WordsAny is the same "any" switch. Empty means no filter.
|
||||
Words []string
|
||||
WordsAny bool
|
||||
|
||||
// HasAttachment, AttachmentType and AttachmentNamePattern (issue 1229, AC5, AC9) filter by attachment:
|
||||
// HasAttachment requires at least one; AttachmentType (an exact media type, case-insensitive) and
|
||||
// AttachmentNamePattern (a shell glob such as "*.pdf", case-insensitive) each require one attachment matching
|
||||
// it, but when both are given, either suffices for the same attachment (AC9: "type text/calendar or name
|
||||
// *.ics", since a calendar invite may carry either). Empty/false means no filter.
|
||||
HasAttachment bool
|
||||
AttachmentType string
|
||||
AttachmentNamePattern string
|
||||
|
||||
// Unread (issue 1229, carrying 1228/AC2's flag part) is nil for no filter; true answers only messages without
|
||||
// the \Seen flag, false only those with it.
|
||||
Unread *bool
|
||||
|
||||
// OrderBy and OrderDesc (issue 1230, AC7, AC11, AC17's schema) choose Search's sort: empty OrderBy means
|
||||
// "date" with OrderDesc ignored, kept descending (today's newest-first default, AC1); given explicitly, one
|
||||
// of "date", "sender" (by its first address), "subject" or "size", ascending unless OrderDesc. Ties always
|
||||
// break ascending on (account, folder, uid). GroupBySender ignores both: its own order is fixed (AC10).
|
||||
OrderBy string
|
||||
OrderDesc bool
|
||||
|
||||
// InReplyTo (issue 1231, AC8) matches the In-Reply-To header (in_reply_to, schema.sql) exactly; empty means
|
||||
// no filter. Combined with From, it finds the replies from one recipient; alone, every reply in the thread's
|
||||
// direct answer set.
|
||||
InReplyTo string
|
||||
|
||||
// FromSomeoneIWroteTo (issue 1231, AC14) is true when the sender's address is a recipient (to or cc) of some
|
||||
// mail in the same account's Sent folder; false (the default) means no filter.
|
||||
FromSomeoneIWroteTo bool
|
||||
}
|
||||
|
||||
// SenderGroup is one line of mail_search's group-by-sender answer (issue 1230, AC10): the sender's address and
|
||||
// name (as last seen on a matching message), how many matching messages it sent, and the newest one's date.
|
||||
type SenderGroup struct {
|
||||
Address string `json:"address"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Count int `json:"count"`
|
||||
Newest time.Time `json:"newest"`
|
||||
}
|
||||
|
||||
// Store is the index.
|
||||
@@ -112,8 +181,14 @@ type Store interface {
|
||||
Remove(ctx context.Context, account, folder string, uid uint32, e *Event) error
|
||||
// Get is one message, with its body.
|
||||
Get(ctx context.Context, account, folder string, uid uint32) (Message, bool, error)
|
||||
// Search finds messages, newest first, without their bodies.
|
||||
// Search finds messages, newest first by default (OrderBy, OrderDesc choose otherwise), without their bodies.
|
||||
Search(ctx context.Context, q Query) ([]Message, error)
|
||||
// Newest is one account's newest n mails, without their bodies, each (account, message-id) counted once the
|
||||
// same way Search answers with no folder named (novox/hq ADR 0439 §3, issue 1282): for the mail bucket.
|
||||
Newest(ctx context.Context, account string, n int) ([]Message, error)
|
||||
// GroupBySender is 1230/AC10: one line per sender among the messages Query's other filters match, by count
|
||||
// descending, Limit bounding the number of lines.
|
||||
GroupBySender(ctx context.Context, q Query) ([]SenderGroup, error)
|
||||
// Folders is every folder the index knows of an account.
|
||||
Folders(ctx context.Context, account string) ([]Folder, error)
|
||||
// Pending is the events not yet said, oldest first; Said removes those said.
|
||||
@@ -230,26 +305,201 @@ func (s *memStore) Get(_ context.Context, account, folder string, uid uint32) (M
|
||||
return m, ok, nil
|
||||
}
|
||||
|
||||
// matchesQuery is every filter of Query but its order and its limit, shared by Search and GroupBySender so the
|
||||
// two never drift apart (mirrored, condition for condition, by PgStore's own WHERE builder, pgstore.go). wroteTo
|
||||
// is wroteToByAccount's answer, needed only when q.FromSomeoneIWroteTo is set.
|
||||
func matchesQuery(m Message, q Query, words []string, wroteTo map[string]map[string]bool) bool {
|
||||
if !contains(q.Accounts, m.Account) || (q.Folder != "" && q.Folder != m.Folder) {
|
||||
return false
|
||||
}
|
||||
// 1231/AC8: the In-Reply-To header, matched exactly.
|
||||
if q.InReplyTo != "" && m.InReplyTo != q.InReplyTo {
|
||||
return false
|
||||
}
|
||||
// 1231/AC14: the sender's address is a recipient (to or cc) of some mail in the same account's Sent folder.
|
||||
if q.FromSomeoneIWroteTo && !anyAddressIn(m.From, wroteTo[m.Account]) {
|
||||
return false
|
||||
}
|
||||
// 1228/AC2, 1228/AC3, 1228/AC4, 1228/AC6(cc); 1230/AC11 (From's several domains): the address and date-range
|
||||
// filters, mirroring PgStore.Search.
|
||||
if !addressMatchesAny(m.From, q.From) || !addressMatches(m.To, q.To) || !addressMatches(m.Cc, q.Cc) {
|
||||
return false
|
||||
}
|
||||
if !q.OnOrAfter.IsZero() && m.Date.Before(q.OnOrAfter) {
|
||||
return false
|
||||
}
|
||||
if !q.Before.IsZero() && !m.Date.Before(q.Before) {
|
||||
return false
|
||||
}
|
||||
// 1229/AC13: the subject-only and body-only word filters, mirroring PgStore.Search.
|
||||
if !wordMatches([]string{m.Subject}, q.SubjectWords, q.SubjectWordsAny) ||
|
||||
!wordMatches([]string{m.Body}, q.BodyWords, q.BodyWordsAny) {
|
||||
return false
|
||||
}
|
||||
// 1229/AC5, AC6(words), AC12, AC15 (review round 1 finding 3): a word found in subject or body.
|
||||
if !wordMatches([]string{m.Subject, m.Body}, q.Words, q.WordsAny) {
|
||||
return false
|
||||
}
|
||||
// 1229/AC9: the attachment and read/unread filters, mirroring PgStore.Search.
|
||||
if !attachmentMatches(m.Attachments, q.HasAttachment, q.AttachmentType, q.AttachmentNamePattern) {
|
||||
return false
|
||||
}
|
||||
if q.Unread != nil && hasSeenFlag(m.Flags) == *q.Unread {
|
||||
return false
|
||||
}
|
||||
hay := strings.ToLower(m.Subject + " " + m.Body + " " + peopleText(m.From) + " " + peopleText(m.To) + " " + peopleText(m.Cc))
|
||||
for _, w := range words {
|
||||
if !strings.Contains(hay, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *memStore) Search(_ context.Context, q Query) ([]Message, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
words := strings.Fields(strings.ToLower(q.Text))
|
||||
wroteTo := s.wroteToByAccount(q.FromSomeoneIWroteTo)
|
||||
var out []Message
|
||||
for _, m := range s.messages {
|
||||
if !contains(q.Accounts, m.Account) || (q.Folder != "" && q.Folder != m.Folder) {
|
||||
continue
|
||||
}
|
||||
hay := strings.ToLower(m.Subject + " " + m.Body + " " + peopleText(m.From) + " " + peopleText(m.To) + " " + peopleText(m.Cc))
|
||||
all := true
|
||||
for _, w := range words {
|
||||
all = all && strings.Contains(hay, w)
|
||||
}
|
||||
if all {
|
||||
if matchesQuery(m, q, words, wroteTo) {
|
||||
m.Body = ""
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Date.After(out[j].Date) })
|
||||
// 1248/AC1, AC2, AC4-AC7: no folder named, so answer each (account, message-id) once, before order and limit
|
||||
// are applied to the de-duplicated set; a named folder (AC3) skips this.
|
||||
if q.Folder == "" {
|
||||
out = dedupeArchive(out)
|
||||
}
|
||||
// 1230: order by date (the default), sender, subject or size, ascending or descending, tied-broken on
|
||||
// (account, folder, uid) for a stable order.
|
||||
sort.Slice(out, func(i, j int) bool { return orderLess(out[i], out[j], q.OrderBy, q.OrderDesc) })
|
||||
if q.Limit > 0 && len(out) > q.Limit {
|
||||
out = out[:q.Limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Newest is 1282: one account's newest n mails, via Search's own ordering (newest first) and deduplication (no
|
||||
// folder named, so dedupeArchive applies — the same copy Search itself would answer).
|
||||
func (s *memStore) Newest(ctx context.Context, account string, n int) ([]Message, error) {
|
||||
return s.Search(ctx, Query{Accounts: []string{account}, Limit: n})
|
||||
}
|
||||
|
||||
// orderLess is 1230 (AC7, AC11, AC17's schema): date (the default when orderBy is empty), sender (by its first
|
||||
// address), subject or size, ascending unless desc; ties break on (account, folder, uid), always ascending, so
|
||||
// the order is stable regardless of the map order Go would otherwise give equal messages.
|
||||
func orderLess(a, b Message, orderBy string, desc bool) bool {
|
||||
if orderBy == "" {
|
||||
// Unspecified order keeps the newest-first default (AC1), regardless of OrderDesc.
|
||||
desc = true
|
||||
}
|
||||
var cmp int
|
||||
switch orderBy {
|
||||
case "sender":
|
||||
cmp = strings.Compare(firstAddress(a.From), firstAddress(b.From))
|
||||
case "subject":
|
||||
cmp = strings.Compare(a.Subject, b.Subject)
|
||||
case "size":
|
||||
cmp = compareInt64(a.Size, b.Size)
|
||||
default: // "date", or empty (the default)
|
||||
cmp = compareTime(a.Date, b.Date)
|
||||
}
|
||||
if desc {
|
||||
cmp = -cmp
|
||||
}
|
||||
if cmp != 0 {
|
||||
return cmp < 0
|
||||
}
|
||||
if a.Account != b.Account {
|
||||
return a.Account < b.Account
|
||||
}
|
||||
if a.Folder != b.Folder {
|
||||
return a.Folder < b.Folder
|
||||
}
|
||||
return a.UID < b.UID
|
||||
}
|
||||
|
||||
func firstAddress(ps []Person) string {
|
||||
if len(ps) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(ps[0].Address)
|
||||
}
|
||||
|
||||
func compareInt64(a, b int64) int {
|
||||
switch {
|
||||
case a < b:
|
||||
return -1
|
||||
case a > b:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func compareTime(a, b time.Time) int {
|
||||
switch {
|
||||
case a.Before(b):
|
||||
return -1
|
||||
case a.After(b):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// GroupBySender is 1230 (AC10): one line per sender among the messages Query's other filters match (its OrderBy
|
||||
// and OrderDesc are not used; the lines are always ordered by count descending, address ascending to break ties),
|
||||
// Limit bounding the number of lines, not the number of messages.
|
||||
func (s *memStore) GroupBySender(_ context.Context, q Query) ([]SenderGroup, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
words := strings.Fields(strings.ToLower(q.Text))
|
||||
wroteTo := s.wroteToByAccount(q.FromSomeoneIWroteTo)
|
||||
var matched []Message
|
||||
for _, m := range s.messages {
|
||||
if matchesQuery(m, q, words, wroteTo) {
|
||||
matched = append(matched, m)
|
||||
}
|
||||
}
|
||||
// 1248/AC4: no folder named, so a mail held in two folders (one of them the archive) counts once.
|
||||
if q.Folder == "" {
|
||||
matched = dedupeArchive(matched)
|
||||
}
|
||||
byAddress := map[string]*SenderGroup{}
|
||||
var order []string
|
||||
for _, m := range matched {
|
||||
for _, p := range m.From {
|
||||
addr := strings.ToLower(p.Address)
|
||||
g, ok := byAddress[addr]
|
||||
if !ok {
|
||||
g = &SenderGroup{}
|
||||
byAddress[addr] = g
|
||||
order = append(order, addr)
|
||||
}
|
||||
g.Count++
|
||||
// Review round 1 finding 6: Address and Name are the newest mail's own, never whichever message a
|
||||
// Go map's (randomised) iteration order happened to visit first.
|
||||
if g.Newest.IsZero() || m.Date.After(g.Newest) {
|
||||
g.Newest = m.Date
|
||||
g.Address = p.Address
|
||||
g.Name = p.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]SenderGroup, 0, len(order))
|
||||
for _, addr := range order {
|
||||
out = append(out, *byAddress[addr])
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Count != out[j].Count {
|
||||
return out[i].Count > out[j].Count
|
||||
}
|
||||
return strings.ToLower(out[i].Address) < strings.ToLower(out[j].Address)
|
||||
})
|
||||
if q.Limit > 0 && len(out) > q.Limit {
|
||||
out = out[:q.Limit]
|
||||
}
|
||||
@@ -321,6 +571,219 @@ func contains(list []string, s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// addressMatches is 1228/AC2, 1228/AC3, 1228/AC4, 1228/AC6(cc): filter is an address (matched exactly) or a domain
|
||||
// (matched as the suffix after "@"), case-insensitive; empty filter matches every message.
|
||||
func addressMatches(ps []Person, filter string) bool {
|
||||
if filter == "" {
|
||||
return true
|
||||
}
|
||||
f := strings.ToLower(filter)
|
||||
for _, p := range ps {
|
||||
addr := strings.ToLower(p.Address)
|
||||
if addr == f || strings.HasSuffix(addr, "@"+f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// addressMatchesAny is 1230/AC11: From's several domains (or addresses), OR'd together; empty/nil matches every
|
||||
// message, otherwise at least one entry (each matched as addressMatches does) must match.
|
||||
func addressMatchesAny(ps []Person, filters []string) bool {
|
||||
if len(filters) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, f := range filters {
|
||||
if addressMatches(ps, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sentFolderNames (issue 1231, AC14; review round 1 finding 1) are the names the live index holds a Sent folder
|
||||
// under, matched case-insensitively: "Sent" for mesh and mediahuis (mailu), "[Gmail]/Sent Mail" for gmail. A
|
||||
// future account naming it a third way is for the tool layer to surface (the context's "say what you chose"),
|
||||
// not this store.
|
||||
var sentFolderNames = []string{"Sent", "[Gmail]/Sent Mail"}
|
||||
|
||||
func isSentFolder(folder string) bool {
|
||||
for _, n := range sentFolderNames {
|
||||
if strings.EqualFold(folder, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// archiveFolderNames (issue 1248, AC1-AC7) are the folders where an account keeps a second copy of every mail
|
||||
// (gmail's special-use \All, called "[Gmail]/All Mail" in its UI): searched only when named (AC3). A search
|
||||
// naming no folder answers each (account, message-id) once, preferring the copy outside this list (AC1), and
|
||||
// falling back to the archive copy only when that is the only one (AC2). Matched case-insensitively, like
|
||||
// sentFolderNames; a future account naming its archive a third way is for the tool layer to surface, not this
|
||||
// store.
|
||||
var archiveFolderNames = []string{"[Gmail]/All Mail"}
|
||||
|
||||
func isArchiveFolder(folder string) bool {
|
||||
for _, n := range archiveFolderNames {
|
||||
if strings.EqualFold(folder, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dedupeArchive is 1248/AC1, AC2, AC4-AC7: ms (already matching a Query with no Folder named) with each
|
||||
// (account, message-id) kept once — the copy outside archiveFolderNames, or the archive copy when that is the
|
||||
// only copy. An empty message-id (AC5) is never merged with anything: every such mail is its own group, keyed by
|
||||
// its own (account, folder, uid), which msgKey already makes unique. Which copy wins when a message-id sits in
|
||||
// two non-archive folders too (e.g. INBOX and a label folder such as "INBOX/Telenet"; review round 1 finding 1)
|
||||
// is decided by dedupeBetter's fixed rule, the same one dedupedMessagesSQL (pgstore.go) uses, never by the order
|
||||
// ms happens to arrive in — which, from a Go map, is randomised per run.
|
||||
func dedupeArchive(ms []Message) []Message {
|
||||
type groupKey struct{ account, id string }
|
||||
kept := map[groupKey]int{} // groupKey -> index into out
|
||||
var out []Message
|
||||
for _, m := range ms {
|
||||
id := m.MessageID
|
||||
if id == "" {
|
||||
id = "\x00" + m.Folder + "\x00" + itoa(int64(m.UID))
|
||||
}
|
||||
k := groupKey{m.Account, id}
|
||||
if i, ok := kept[k]; ok {
|
||||
if dedupeBetter(m, out[i]) {
|
||||
out[i] = m
|
||||
}
|
||||
continue
|
||||
}
|
||||
kept[k] = len(out)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeBetter is 1248 (review round 1, finding 1): whether a is the copy dedupeArchive should keep over b,
|
||||
// within one (account, message-id) group — non-archive before archive, then folder ascending, then uid
|
||||
// ascending, the same order dedupedMessagesSQL's "order by ... dedup_archived, folder, uid" picks in Postgres,
|
||||
// so the two stores never answer a different copy of the same mail.
|
||||
func dedupeBetter(a, b Message) bool {
|
||||
aArchived, bArchived := isArchiveFolder(a.Folder), isArchiveFolder(b.Folder)
|
||||
if aArchived != bArchived {
|
||||
return !aArchived
|
||||
}
|
||||
if a.Folder != b.Folder {
|
||||
return a.Folder < b.Folder
|
||||
}
|
||||
return a.UID < b.UID
|
||||
}
|
||||
|
||||
// wroteToByAccount is 1231/AC14's own data: for each account, every address (lowercased) that account has
|
||||
// written to (as a "to" or a "cc") from a folder named one of sentFolderNames. Computed only when needed (asked
|
||||
// is false otherwise), since it walks every message.
|
||||
func (s *memStore) wroteToByAccount(asked bool) map[string]map[string]bool {
|
||||
if !asked {
|
||||
return nil
|
||||
}
|
||||
out := map[string]map[string]bool{}
|
||||
for _, m := range s.messages {
|
||||
if !isSentFolder(m.Folder) {
|
||||
continue
|
||||
}
|
||||
set, ok := out[m.Account]
|
||||
if !ok {
|
||||
set = map[string]bool{}
|
||||
out[m.Account] = set
|
||||
}
|
||||
for _, p := range append(append([]Person{}, m.To...), m.Cc...) {
|
||||
set[strings.ToLower(p.Address)] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// anyAddressIn is 1231/AC14: whether any of ps's addresses (case-insensitive) is in set; an unset or empty set
|
||||
// (the account wrote to nobody, or wroteTo was never asked for) matches nobody.
|
||||
func anyAddressIn(ps []Person, set map[string]bool) bool {
|
||||
for _, p := range ps {
|
||||
if set[strings.ToLower(p.Address)] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// wordRegexp is a whole-word, case-insensitive match (issue 1229, AC12): \b, Go's RE2 word boundary, treats a
|
||||
// dash as a non-word character, so a hyphenated word such as "4711-882" is bounded at both ends and never matched
|
||||
// by only its digits.
|
||||
func wordRegexp(word string) *regexp.Regexp {
|
||||
return regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(word) + `\b`)
|
||||
}
|
||||
|
||||
// wordMatches is 1229/AC5, AC6(words), AC9, AC12, AC13, AC15; review round 1 finding 3: an empty list matches
|
||||
// every message; otherwise every word of the list must be found in at least one of texts ("all"), or at least
|
||||
// one word found in at least one text when any is true. SubjectWords/BodyWords call it with one text (their own
|
||||
// field, and only that field); Words calls it with both Subject and Body, so a word counts in either — "the
|
||||
// word in subject or body", as the acceptance criteria ask it.
|
||||
func wordMatches(texts []string, words []string, any bool) bool {
|
||||
if len(words) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, w := range words {
|
||||
re := wordRegexp(w)
|
||||
found := false
|
||||
for _, t := range texts {
|
||||
if re.MatchString(t) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if any && found {
|
||||
return true
|
||||
}
|
||||
if !any && !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return !any
|
||||
}
|
||||
|
||||
// attachmentMatches is 1229/AC5, AC9: hasAttachment requires at least one attachment; typ and namePattern, when
|
||||
// either is given, each require one attachment matching it, but when both are given either suffices for the same
|
||||
// attachment (AC9's "type text/calendar or name *.ics").
|
||||
func attachmentMatches(atts []AttachmentRef, hasAttachment bool, typ, namePattern string) bool {
|
||||
if hasAttachment && len(atts) == 0 {
|
||||
return false
|
||||
}
|
||||
if typ == "" && namePattern == "" {
|
||||
return true
|
||||
}
|
||||
for _, a := range atts {
|
||||
if typ != "" && strings.EqualFold(a.Type, typ) {
|
||||
return true
|
||||
}
|
||||
if namePattern != "" && globMatches(namePattern, a.Name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// globMatches is 1229/AC5, AC9: a shell glob such as "*.pdf" or "*.ics", case-insensitive.
|
||||
func globMatches(pattern, name string) bool {
|
||||
ok, _ := path.Match(strings.ToLower(pattern), strings.ToLower(name))
|
||||
return ok
|
||||
}
|
||||
|
||||
// hasSeenFlag is 1229 (carrying 1228/AC2's flag part): whether the message is read, by IMAP's own \Seen flag.
|
||||
func hasSeenFlag(flags []string) bool {
|
||||
for _, f := range flags {
|
||||
if strings.EqualFold(f, `\Seen`) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func peopleText(ps []Person) string {
|
||||
var parts []string
|
||||
for _, p := range ps {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package main
|
||||
|
||||
// The tells to the operator, overdue health and mail_send_status's words (novox/hq ADR 0436 clauses 11,
|
||||
// 12, issue 1199).
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// memTell collects every headline a TellOperator was called with, optionally refusing the first n calls
|
||||
// (novox/hq issue 1199).
|
||||
type memTell struct {
|
||||
headlines []string
|
||||
refuseN int
|
||||
}
|
||||
|
||||
func (m *memTell) tell(headline string) error {
|
||||
if m.refuseN > 0 {
|
||||
m.refuseN--
|
||||
return errors.New("the channel is down")
|
||||
}
|
||||
m.headlines = append(m.headlines, headline)
|
||||
return nil
|
||||
}
|
||||
|
||||
func tellTestSend(t *testing.T, sends *memSends, outcome string) SendRequest {
|
||||
t.Helper()
|
||||
req := SendRequest{ID: newSendID(), Account: "mesh", To: []string{"ops@example.org"}, At: judgedAt}
|
||||
req.Decided = &SendDecision{At: judgedAt, Outcome: outcome}
|
||||
if _, err := sends.Create(req.ID, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addTellPending(sends, req.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// 1199/AC1: given a send recorded failed, refused or not proven after the operator's Approve, when the
|
||||
// sender looks, then it sets tell to telling by compare-and-set, calls seat:operator-channel.tell once
|
||||
// (the headline naming the account and the reason, or for not proven that it may not have been sent; no
|
||||
// address in it), and on the channel taking it sets told. A refused or unanswered tell leaves telling, and
|
||||
// the next look tells only records left telling. A sent send is never told.
|
||||
func TestTellSendTellsOnceAndSettlesToldOnlyOnceTheChannelTakesIt(t *testing.T) {
|
||||
sends := newMemSends()
|
||||
req := tellTestSend(t, sends, "failed: 550 no such mailbox")
|
||||
tell := &memTell{}
|
||||
|
||||
if err := tellSend(sends, tell.tell, req.ID, judgedAt.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("telling: %v", err)
|
||||
}
|
||||
if len(tell.headlines) != 1 {
|
||||
t.Fatalf("told %d time(s): %v", len(tell.headlines), tell.headlines)
|
||||
}
|
||||
h := tell.headlines[0]
|
||||
if !strings.Contains(h, "mesh") || !strings.Contains(h, "550 no such mailbox") {
|
||||
t.Fatalf("headline: %q", h)
|
||||
}
|
||||
if strings.Contains(h, "@") {
|
||||
t.Fatalf("the headline carries an address: %q", h)
|
||||
}
|
||||
kept, _, ok, err := getSend(sends, req.ID)
|
||||
if err != nil || !ok || kept.Decided == nil || kept.Decided.Tell != "told" {
|
||||
t.Fatalf("tell not settled told: %v %v %+v", err, ok, kept.Decided)
|
||||
}
|
||||
ids, err := tellPendingIDs(sends)
|
||||
if err != nil || len(ids) != 0 {
|
||||
t.Fatalf("still indexed after being told: %v %v", err, ids)
|
||||
}
|
||||
|
||||
// A second look at an already-told send tells nothing more.
|
||||
if err := tellSend(sends, tell.tell, req.ID, judgedAt.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("telling again: %v", err)
|
||||
}
|
||||
if len(tell.headlines) != 1 {
|
||||
t.Fatalf("told again: %v", tell.headlines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTellSendLeavesTellingOnARefusedChannelAndTriesAgain(t *testing.T) {
|
||||
sends := newMemSends()
|
||||
req := tellTestSend(t, sends, "not proven")
|
||||
tell := &memTell{refuseN: 1}
|
||||
|
||||
if err := tellSend(sends, tell.tell, req.ID, judgedAt.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("telling: %v", err)
|
||||
}
|
||||
kept, _, ok, err := getSend(sends, req.ID)
|
||||
if err != nil || !ok || kept.Decided == nil || kept.Decided.Tell != "telling" {
|
||||
t.Fatalf("not left telling: %v %v %+v", err, ok, kept.Decided)
|
||||
}
|
||||
ids, err := tellPendingIDs(sends)
|
||||
if err != nil || len(ids) != 1 {
|
||||
t.Fatalf("no longer indexed despite the refusal: %v %v", err, ids)
|
||||
}
|
||||
|
||||
// The next look succeeds.
|
||||
if err := tellSend(sends, tell.tell, req.ID, judgedAt.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("telling again: %v", err)
|
||||
}
|
||||
if len(tell.headlines) != 1 {
|
||||
t.Fatalf("told: %v", tell.headlines)
|
||||
}
|
||||
if !strings.Contains(tell.headlines[0], "may not have been sent") {
|
||||
t.Fatalf("not-proven headline: %q", tell.headlines[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTellSendNeverTellsForASentSend(t *testing.T) {
|
||||
sends := newMemSends()
|
||||
req := SendRequest{ID: newSendID(), Account: "mesh", At: judgedAt}
|
||||
req.Decided = &SendDecision{At: judgedAt, Outcome: "sent"}
|
||||
if _, err := sends.Create(req.ID, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tell := &memTell{}
|
||||
if err := tellSend(sends, tell.tell, req.ID, judgedAt.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("telling: %v", err)
|
||||
}
|
||||
if len(tell.headlines) != 0 {
|
||||
t.Fatalf("a sent send was told: %v", tell.headlines)
|
||||
}
|
||||
}
|
||||
|
||||
// review round 1 finding 3: a send indexed for a tell before its own outcome write has landed (recordOutcome
|
||||
// and claimQueuedSend now index first) must not be dropped by a look that finds it still sending, queued or
|
||||
// retrying — it is left in the index for the next look, once the real outcome lands.
|
||||
func TestTellSendKeepsAnEntryWhoseRecordIsStillInFlight(t *testing.T) {
|
||||
for _, outcome := range []string{"sending", "queued", "retrying"} {
|
||||
sends := newMemSends()
|
||||
req := SendRequest{ID: newSendID(), Account: "mesh", At: judgedAt}
|
||||
req.Decided = &SendDecision{At: judgedAt, Outcome: outcome}
|
||||
if _, err := sends.Create(req.ID, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addTellPending(sends, req.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tell := &memTell{}
|
||||
if err := tellSend(sends, tell.tell, req.ID, judgedAt.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("%s: telling: %v", outcome, err)
|
||||
}
|
||||
if len(tell.headlines) != 0 {
|
||||
t.Fatalf("%s: told before its outcome landed: %v", outcome, tell.headlines)
|
||||
}
|
||||
ids, err := tellPendingIDs(sends)
|
||||
if err != nil || len(ids) != 1 {
|
||||
t.Fatalf("%s: dropped from the index while still in flight: %v %v", outcome, err, ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1199/AC2: given a send queued more than 10 minutes ago, or retrying 10 minutes past its next attempt,
|
||||
// when mail_health runs (Overdue), then it names the send; a send waiting for its next attempt is never
|
||||
// overdue.
|
||||
func TestOverdueFindsAQueuedOrRetryingSendPastTenMinutes(t *testing.T) {
|
||||
sends := newMemSends()
|
||||
queueTestSend := func(outcome string, at, next time.Time) string {
|
||||
req := SendRequest{ID: newSendID(), Account: "mesh", At: judgedAt}
|
||||
req.Decided = &SendDecision{At: at, Outcome: outcome, NextAttempt: next}
|
||||
if _, err := sends.Create(req.ID, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addSenderPending(sends, req.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return req.ID
|
||||
}
|
||||
now := judgedAt
|
||||
overdueQueued := queueTestSend("queued", now.Add(-11*time.Minute), time.Time{})
|
||||
freshQueued := queueTestSend("queued", now.Add(-5*time.Minute), time.Time{})
|
||||
overdueRetrying := queueTestSend("retrying", now.Add(-20*time.Minute), now.Add(-11*time.Minute))
|
||||
notYetDueRetrying := queueTestSend("retrying", now.Add(-5*time.Minute), now.Add(3*time.Minute))
|
||||
sendingNotOverdue := queueTestSend("sending", now.Add(-20*time.Minute), time.Time{})
|
||||
|
||||
overdue, err := Overdue(sends, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Overdue: %v", err)
|
||||
}
|
||||
var ids []string
|
||||
for _, r := range overdue {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
want := map[string]bool{overdueQueued: true, overdueRetrying: true}
|
||||
if len(ids) != len(want) {
|
||||
t.Fatalf("overdue: %v, want exactly %v", ids, want)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !want[id] {
|
||||
t.Fatalf("unexpectedly overdue: %s (fresh %s, not-yet-due %s, sending %s)", id, freshQueued, notYetDueRetrying, sendingNotOverdue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1199/AC3: given an overdue send, when the tools' second ticker looks, then they tell once through
|
||||
// waits-tell (telling then told), separate from the sender's own tell.
|
||||
func TestTellWaitsTellsOnceThroughItsOwnField(t *testing.T) {
|
||||
sends := newMemSends()
|
||||
req := SendRequest{ID: newSendID(), Account: "mesh", At: judgedAt}
|
||||
req.Decided = &SendDecision{At: judgedAt.Add(-20 * time.Minute), Outcome: "queued"}
|
||||
if _, err := sends.Create(req.ID, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tell := &memTell{}
|
||||
if err := tellWaits(sends, tell.tell, req.ID, judgedAt); err != nil {
|
||||
t.Fatalf("telling waits: %v", err)
|
||||
}
|
||||
if len(tell.headlines) != 1 || !strings.Contains(tell.headlines[0], "waits") {
|
||||
t.Fatalf("waits-tell: %v", tell.headlines)
|
||||
}
|
||||
kept, _, ok, err := getSend(sends, req.ID)
|
||||
if err != nil || !ok || kept.Decided == nil || kept.Decided.WaitsTell != "told" || kept.Decided.Tell != "" {
|
||||
t.Fatalf("waits-tell not settled apart from the sender's own tell: %v %v %+v", err, ok, kept.Decided)
|
||||
}
|
||||
// Told once: a second look tells nothing more.
|
||||
if err := tellWaits(sends, tell.tell, req.ID, judgedAt.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("telling waits again: %v", err)
|
||||
}
|
||||
if len(tell.headlines) != 1 {
|
||||
t.Fatalf("told again: %v", tell.headlines)
|
||||
}
|
||||
}
|
||||
|
||||
// 1199/AC4: given each state, when mail_send_status reads it (reportDecision), then it says: waiting for
|
||||
// the operator (covered by send_test.go's own asking tests, which read ReadSendStatus before any decision
|
||||
// exists — reportDecision itself only ever sees an already-decided SendDecision), approved and waiting for
|
||||
// the sender, being sent, waiting for its next attempt, sent, failed with its reason, refused, not proven,
|
||||
// declined, or expired — checking both the status word and the words that actually say which of these it
|
||||
// is (novox/hq review round 1 finding 6: checking status alone proves too little).
|
||||
func TestReportDecisionCoversTheWholeStatusVocabulary(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
d SendDecision
|
||||
wantStatus string
|
||||
wantInWords string
|
||||
}{
|
||||
// There is no "approved" state in a send's life: Decide's one compare-and-set write moves it
|
||||
// straight from undecided to "queued" (novox/hq ADR 0436 clause 8, as decided to replace the
|
||||
// two-state design, review round 1).
|
||||
"queued": {SendDecision{At: judgedAt, Outcome: "queued"}, "waiting", "approved and waiting for the sender"},
|
||||
"being sent": {SendDecision{At: judgedAt, Outcome: "sending"}, "waiting", "being sent"},
|
||||
"retrying": {SendDecision{At: judgedAt, Outcome: "retrying", NextAttempt: judgedAt.Add(time.Minute)}, "waiting", "waiting for its next attempt"},
|
||||
"sent": {SendDecision{At: judgedAt, Outcome: "sent"}, "sent", "sent on the operator's Approve"},
|
||||
"failed with its reason": {SendDecision{At: judgedAt, Outcome: "failed: 550 no such mailbox"}, "failed", "550 no such mailbox"},
|
||||
"refused": {SendDecision{At: judgedAt, Outcome: "refused: the warrant does not bind the request"}, "refused", "the warrant does not bind the request"},
|
||||
"not proven": {SendDecision{At: judgedAt, Outcome: "not proven"}, "not proven", "not proven"},
|
||||
"declined": {SendDecision{At: judgedAt, Outcome: "declined"}, "declined", "the operator declined"},
|
||||
"expired (never heard)": {SendDecision{At: judgedAt, Outcome: "ended: never heard"}, "expired", "never heard"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
out := map[string]any{}
|
||||
reportDecision(out, c.d, judgedAt.Add(time.Minute))
|
||||
if out["status"] != c.wantStatus {
|
||||
t.Fatalf("status: %v, want %q (words: %v)", out["status"], c.wantStatus, out["words"])
|
||||
}
|
||||
words, _ := out["words"].(string)
|
||||
if !strings.Contains(words, c.wantInWords) {
|
||||
t.Fatalf("words: %q, want it to contain %q", words, c.wantInWords)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,11 @@ const (
|
||||
// its verbs through the same handlers as the mail_* tools above — the seat's name is the one a caller grants
|
||||
// reach the same account's mail through, whichever name it calls.
|
||||
const (
|
||||
SeatAccountsTool = "mail-store.accounts"
|
||||
SeatSearchTool = "mail-store.search"
|
||||
SeatReadTool = "mail-store.read"
|
||||
SeatSendTool = "mail-store.send"
|
||||
SeatAccountsTool = "mail-store.accounts"
|
||||
SeatSearchTool = "mail-store.search"
|
||||
SeatReadTool = "mail-store.read"
|
||||
SeatSendTool = "mail-store.send"
|
||||
SeatSendStatusTool = "mail-store.send-status"
|
||||
)
|
||||
|
||||
// Bounds of what a tool answers.
|
||||
@@ -45,9 +46,10 @@ const (
|
||||
)
|
||||
|
||||
// Bundle is what the tools reach: the settings, the index, the attachment store, and — for mail_send — the
|
||||
// operator channel, the `sends` state and the outgoing dialer. The last three are nil outside a test: the real
|
||||
// bus and dialer (sdkAskBus, keptSends, DialSMTP) are used lazily, so building a Bundle for the read-only tools
|
||||
// never needs them.
|
||||
// operator channel, the `sends` state and the work-queue publisher. The last three are nil outside a test: the
|
||||
// real bus (sdkAskBus, keptSends, sdkQueuePublish) is used lazily, so building a Bundle for the read-only tools
|
||||
// never needs them. Nothing here reads a password: the tools record the operator's Approve and queue the send
|
||||
// for the sender, which alone performs it (novox/hq ADR 0436 §12, issue 1193, 1194).
|
||||
type Bundle struct {
|
||||
Env Env
|
||||
Index func(ctx context.Context) (Store, error)
|
||||
@@ -57,7 +59,7 @@ type Bundle struct {
|
||||
|
||||
Asks AskBus
|
||||
Sends SendStore
|
||||
Dial SMTPDialer
|
||||
Queue QueuePublish
|
||||
Logf func(format string, a ...any)
|
||||
}
|
||||
|
||||
@@ -102,6 +104,54 @@ func (a access) refuse(caller, account string) error {
|
||||
return refused(caller, account)
|
||||
}
|
||||
|
||||
// searchArgs names mail_search's own arguments, used both for its schema (issue 1232, AC17) and for only's
|
||||
// guard, so the two can never drift apart.
|
||||
var searchArgs = []string{
|
||||
"query", "account", "folder", "limit", "from", "to", "cc", "on-or-after", "before", "words", "words-any",
|
||||
"subject-words", "subject-words-any", "body-words", "body-words-any", "has-attachment", "attachment-type", "attachment-name",
|
||||
"unread", "order-by", "order", "group-by-sender", "in-reply-to", "from-someone-i-wrote-to",
|
||||
}
|
||||
|
||||
// searchToolSchema is mail_search's one schema (issue 1232: SearchTool and SeatSearchTool share it, not two
|
||||
// written by hand): query is optional, and every filter and order-by names its own form (AC17).
|
||||
func searchToolSchema() map[string]any {
|
||||
return schema(nil, map[string]any{
|
||||
"query": text("words to find across subject, text, sender or recipient, every one required; optional — " +
|
||||
"with no filter at all, the newest mails of your granted accounts"),
|
||||
"account": text("one account by its name in mail_accounts; every account you are granted when left out"),
|
||||
"folder": text("one folder, such as INBOX; every folder when left out"),
|
||||
"limit": integer("how many to answer (lines, with group-by-sender), at most 50 (20 when left out)"),
|
||||
"from": stringArray("filter by sender: one or more addresses or domains (a domain, no \"@\", matches every " +
|
||||
"address of it), case-insensitive; any one of them matching is enough"),
|
||||
"to": text("filter by recipient: an address or a domain, case-insensitive"),
|
||||
"cc": text("filter by a cc recipient: an address or a domain, case-insensitive"),
|
||||
"on-or-after": text("only mails dated on or after this (RFC3339, or YYYY-MM-DD for the whole day)"),
|
||||
"before": text("only mails dated before this (RFC3339, or YYYY-MM-DD for the whole day)"),
|
||||
"words": stringArray("every one of these whole words (case-insensitive) must be in the subject or the " +
|
||||
"body (either counts), unless words-any"),
|
||||
"words-any": boolean("words matches when any one of them is found, not every one"),
|
||||
"subject-words": stringArray("every one of these whole words (case-insensitive) must be in the subject " +
|
||||
"(only the subject; words, above, takes the subject or the body), unless subject-words-any"),
|
||||
"subject-words-any": boolean("subject-words matches when any one of them is found, not every one"),
|
||||
"body-words": stringArray("every one of these whole words (case-insensitive) must be in the text (only " +
|
||||
"the body; words, above, takes the subject or the body), unless body-words-any"),
|
||||
"body-words-any": boolean("body-words matches when any one of them is found, not every one"),
|
||||
"has-attachment": boolean("only mails carrying at least one attachment"),
|
||||
"attachment-type": text("only mails carrying an attachment of this exact media type, case-insensitive; " +
|
||||
"combined with attachment-name, either suffices for the same attachment"),
|
||||
"attachment-name": text("only mails carrying an attachment whose name matches this glob (such as \"*.pdf\"), " +
|
||||
"case-insensitive; combined with attachment-type, either suffices for the same attachment"),
|
||||
"unread": boolean("true for only unread mail (no \\Seen flag), false for only read mail; every mail when left out"),
|
||||
"order-by": enumText("sort by this field (date, the default, unless order-by is itself left out)", "date", "sender", "subject", "size"),
|
||||
"order": enumText("ascending or descending; desc when left out (newest or largest first)", "asc", "desc"),
|
||||
"group-by-sender": boolean("answer one line per sender (address, name, count, newest date) instead of the " +
|
||||
"mails themselves, by count descending; limit bounds the lines, not the mails"),
|
||||
"in-reply-to": text("only replies to this exact Message-Id (its In-Reply-To header)"),
|
||||
"from-someone-i-wrote-to": boolean("only mail whose sender is a recipient of a mail you sent, from the " +
|
||||
"same account's own Sent folder (\"Sent\", or \"[Gmail]/Sent Mail\" for a gmail account)"),
|
||||
})
|
||||
}
|
||||
|
||||
// Tools are the module's tools.
|
||||
func (b *Bundle) Tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
@@ -113,17 +163,19 @@ func (b *Bundle) Tools() []stdio.Tool {
|
||||
Input: schema(nil, map[string]any{}),
|
||||
Run: b.accountsTool},
|
||||
{Name: SearchTool,
|
||||
Description: "Search the mail of the accounts you are granted: every word of the query must be in the subject, the " +
|
||||
"text, a sender or a recipient. Answers the newest first, at most 50: account, folder, uid, message id, date, " +
|
||||
"sender, recipients, subject, size, flags and attachment references, never a body. Refused for an account you " +
|
||||
"are not granted, and when you are granted none. Read-only.",
|
||||
Input: schema([]string{"query"}, map[string]any{
|
||||
"query": text("the words to find, at most 200 characters"),
|
||||
"account": text("one account by its name in mail_accounts; every account you are granted when left out"),
|
||||
"folder": text("one folder, such as INBOX; every folder when left out"),
|
||||
"limit": integer("how many to answer, at most 50 (20 when left out)"),
|
||||
}),
|
||||
Run: b.searchTool},
|
||||
Description: "Search the mail of the accounts you are granted. Query is optional: with no filter at all, the " +
|
||||
"newest mails of your granted accounts. Every filter is optional and combines with the others (and): account, " +
|
||||
"folder, from/to/cc (an address or a domain; from takes several, any one matching), a date range, " +
|
||||
"words (a whole word in the subject or the body, either counts), subject-words/body-words (only that one " +
|
||||
"field; each all unless its own -any), has-attachment, attachment-type/attachment-name " +
|
||||
"(either matching an attachment suffices), unread, in-reply-to, from-someone-i-wrote-to. Order by date (the " +
|
||||
"default), sender, subject or size, asc or desc; group-by-sender answers one line per sender (address, name, " +
|
||||
"count, newest) instead. At most 50 (20 default). Answers account, folder, uid, message id, date, sender, " +
|
||||
"recipients, subject, size, flags and attachment references, never a body. Refused, naming the argument, for " +
|
||||
"an unknown order-by, a limit over 50, a date that does not parse, or an account you are not granted — before " +
|
||||
"the index is ever asked. Read-only.",
|
||||
Input: searchToolSchema(),
|
||||
Run: b.searchTool},
|
||||
{Name: ReadTool,
|
||||
Description: "Read one message of an account you are granted, as an event or mail_search names it (account, folder, " +
|
||||
"uid): its metadata and its text (the plain part, or the HTML part as text, at most 64 KiB here). With " +
|
||||
@@ -138,10 +190,11 @@ func (b *Bundle) Tools() []stdio.Tool {
|
||||
Run: b.readTool},
|
||||
{Name: HealthTool,
|
||||
Description: "How mesh-mail is, as its watch last saw it: healthy when every account's server answered within five " +
|
||||
"minutes; unhealthy, naming the account and why, when a connection is down or its IDLE on the inbox went silent; " +
|
||||
"waits, naming the account's password, while the operator has not given it at the desk.",
|
||||
"minutes; unhealthy, naming the account and why, when a connection is down or its IDLE on the inbox went silent, " +
|
||||
"or an approved send has waited 10 minutes or more for its sender; waits, naming the account's password, while " +
|
||||
"the operator has not given it at the desk.",
|
||||
Input: schema(nil, map[string]any{}),
|
||||
Run: func(map[string]any) (any, error) { return ReadStatus(b.Env.Status, b.now()), nil }},
|
||||
Run: b.healthTool},
|
||||
{Name: SendTool,
|
||||
Description: "Send mail from an account you are granted (the same check as mail_read, novox/hq ADR 0361 §2). It " +
|
||||
"sends nothing itself: it raises an ask on the operator channel — the account, the recipients, the subject and " +
|
||||
@@ -160,7 +213,9 @@ func (b *Bundle) Tools() []stdio.Tool {
|
||||
{Name: SendStatusTool,
|
||||
Description: "Where a send asked with mail_send stands, by its ask id: waiting for the operator, sent, declined, " +
|
||||
"ended without a choice (expired, refused, cancelled), or not proven. Read from the router's own record, held " +
|
||||
"to the request mail_send asked (so a warrant for another request, or one changed since, sends nothing here).",
|
||||
"to the request mail_send asked (so a warrant for another request, or one changed since, sends nothing here). " +
|
||||
"Answered only to the caller that asked for the send: an id this caller did not ask for, known or not, " +
|
||||
"answers status unknown and nothing else of it (novox/hq ADR 0440 §7, issue 1261).",
|
||||
Input: schema([]string{"id"}, map[string]any{"id": text("the ask id mail_send gave")}),
|
||||
Run: b.sendStatusTool},
|
||||
{Name: SeatAccountsTool,
|
||||
@@ -169,13 +224,8 @@ func (b *Bundle) Tools() []stdio.Tool {
|
||||
Run: b.accountsTool},
|
||||
{Name: SeatSearchTool,
|
||||
Description: "The mail-store seat's search verb (novox/hq ADR 0378 §2): the same as mail_search.",
|
||||
Input: schema([]string{"query"}, map[string]any{
|
||||
"query": text("the words to find, at most 200 characters"),
|
||||
"account": text("one account by its name in mail_accounts; every account you are granted when left out"),
|
||||
"folder": text("one folder, such as INBOX; every folder when left out"),
|
||||
"limit": integer("how many to answer, at most 50 (20 when left out)"),
|
||||
}),
|
||||
Run: b.searchTool},
|
||||
Input: searchToolSchema(),
|
||||
Run: b.searchTool},
|
||||
{Name: SeatReadTool,
|
||||
Description: "The mail-store seat's read verb (novox/hq ADR 0378 §2): the same as mail_read.",
|
||||
Input: schema([]string{"account", "folder", "uid"}, map[string]any{
|
||||
@@ -195,6 +245,10 @@ func (b *Bundle) Tools() []stdio.Tool {
|
||||
"body": text("the message's plain-text body"),
|
||||
}),
|
||||
Run: b.sendTool},
|
||||
{Name: SeatSendStatusTool,
|
||||
Description: "The mail-store seat's send-status verb (novox/hq ADR 0378 §2, issue 1261): the same as mail_send_status.",
|
||||
Input: schema([]string{"id"}, map[string]any{"id": text("the ask id mail_send gave")}),
|
||||
Run: b.sendStatusTool},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +259,22 @@ func (b *Bundle) caller(args map[string]any) string {
|
||||
return stdio.CallerOf(args)
|
||||
}
|
||||
|
||||
// healthTool folds an overdue send (novox/hq ADR 0436 clause 11, issue 1199) into the watch's own verdict:
|
||||
// unhealthy, naming the send, no matter what the watch itself said. Overdue reads `sends` directly — the
|
||||
// watch process has no access to it — so a send stuck past its own 10 minutes is caught even while every
|
||||
// account's own connection is fine.
|
||||
func (b *Bundle) healthTool(map[string]any) (any, error) {
|
||||
base := ReadStatus(b.Env.Status, b.now())
|
||||
overdue, err := Overdue(b.sends(), b.now())
|
||||
if err != nil {
|
||||
if b.Logf != nil {
|
||||
b.Logf("[mesh-mail] mail_health: the overdue check failed: %v", err)
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
return WithOverdue(base, overdue), nil
|
||||
}
|
||||
|
||||
func (b *Bundle) accountsTool(args map[string]any) (any, error) {
|
||||
if err := only(args); err != nil {
|
||||
return nil, err
|
||||
@@ -256,13 +326,16 @@ func (b *Bundle) accountsTool(args map[string]any) (any, error) {
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// searchTool is mail_search and mail-store.search (issue 1232): query is optional (AC1), every filter and
|
||||
// order-by issues 1228-1231 built is offered, and every argument is parsed and validated — naming it — before
|
||||
// the index is ever asked (AC16).
|
||||
func (b *Bundle) searchTool(args map[string]any) (any, error) {
|
||||
if err := only(args, "query", "account", "folder", "limit"); err != nil {
|
||||
if err := only(args, searchArgs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query, _ := args["query"].(string)
|
||||
if strings.TrimSpace(query) == "" || len(query) > MostQuery {
|
||||
return nil, fmt.Errorf("%s needs a query of 1 to %d characters", SearchTool, MostQuery)
|
||||
if len(query) > MostQuery {
|
||||
return nil, fmt.Errorf("query is at most %d characters", MostQuery)
|
||||
}
|
||||
account, _ := args["account"].(string)
|
||||
folder, _ := args["folder"].(string)
|
||||
@@ -273,6 +346,72 @@ func (b *Bundle) searchTool(args map[string]any) (any, error) {
|
||||
if limit < 1 || limit > MostResults {
|
||||
return nil, fmt.Errorf("limit is 1 to %d", MostResults)
|
||||
}
|
||||
from, err := stringsArg(args, "from")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to, _ := args["to"].(string)
|
||||
cc, _ := args["cc"].(string)
|
||||
onOrAfter, err := dateArg(args, "on-or-after")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := dateArg(args, "before")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
words, err := stringsArg(args, "words")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wordsAny, err := flag(args, "words-any")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subjectWords, err := stringsArg(args, "subject-words")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subjectWordsAny, err := flag(args, "subject-words-any")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyWords, err := stringsArg(args, "body-words")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyWordsAny, err := flag(args, "body-words-any")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasAttachment, err := flag(args, "has-attachment")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachmentType, _ := args["attachment-type"].(string)
|
||||
attachmentName, _ := args["attachment-name"].(string)
|
||||
unread, err := triBool(args, "unread")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderBy, err := orderByArg(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderDesc, err := orderArg(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupBySender, err := flag(args, "group-by-sender")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inReplyTo, _ := args["in-reply-to"].(string)
|
||||
fromSomeoneIWroteTo, err := flag(args, "from-someone-i-wrote-to")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accounts, grants, err := b.settings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -297,7 +436,24 @@ func (b *Bundle) searchTool(args map[string]any) (any, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the index cannot be reached: %v", err)
|
||||
}
|
||||
found, err := st.Search(ctx, Query{Text: query, Accounts: granted, Folder: folder, Limit: limit})
|
||||
q := Query{
|
||||
Text: query, Accounts: granted, Folder: folder, Limit: limit,
|
||||
From: from, To: to, Cc: cc, OnOrAfter: onOrAfter, Before: before,
|
||||
Words: words, WordsAny: wordsAny,
|
||||
SubjectWords: subjectWords, SubjectWordsAny: subjectWordsAny, BodyWords: bodyWords, BodyWordsAny: bodyWordsAny,
|
||||
HasAttachment: hasAttachment, AttachmentType: attachmentType, AttachmentNamePattern: attachmentName,
|
||||
Unread: unread, OrderBy: orderBy, OrderDesc: orderDesc,
|
||||
InReplyTo: inReplyTo, FromSomeoneIWroteTo: fromSomeoneIWroteTo,
|
||||
}
|
||||
// 1230/AC10: group-by-sender answers a different shape, one line per sender, not the mails themselves.
|
||||
if groupBySender {
|
||||
groups, err := st.GroupBySender(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"searched": granted, "senders": groups}, nil
|
||||
}
|
||||
found, err := st.Search(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -397,14 +553,13 @@ func (b *Bundle) sends() SendStore {
|
||||
return keptSends{stdio.State(SendsState)}
|
||||
}
|
||||
|
||||
// lookupAccount resolves an account by name as the settings currently hold it — read fresh at decide time, never
|
||||
// the account as it stood when it was asked.
|
||||
func (b *Bundle) lookupAccount(name string) (Account, bool) {
|
||||
accounts, err := ReadAccounts(b.Env.Accounts)
|
||||
if err != nil {
|
||||
return Account{}, false
|
||||
// queuePublish is how this bundle publishes onto the module's own work-queue stream: the bus's own, unless a
|
||||
// test gave one. Nothing here reads a password (novox/hq ADR 0436 §12).
|
||||
func (b *Bundle) queuePublish() QueuePublish {
|
||||
if b.Queue != nil {
|
||||
return b.Queue
|
||||
}
|
||||
return accountByName(accounts, name)
|
||||
return sdkQueuePublish
|
||||
}
|
||||
|
||||
func accountByName(accounts []Account, name string) (Account, bool) {
|
||||
@@ -416,25 +571,6 @@ func accountByName(accounts []Account, name string) (Account, bool) {
|
||||
return Account{}, false
|
||||
}
|
||||
|
||||
// sendActor performs one send on the operator's Approve: it reads the account's own secret itself, so Decide and
|
||||
// the `sends` state never hold a password.
|
||||
func (b *Bundle) sendActor() SendActor {
|
||||
dial := b.Dial
|
||||
if dial == nil {
|
||||
dial = DialSMTP
|
||||
}
|
||||
return func(a Account, to []string, subject, body string, now time.Time) error {
|
||||
pw, given, err := ReadSecret(b.Env.Secrets, a)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the account %s's password cannot be read: %w", a.Name, err)
|
||||
}
|
||||
if !given {
|
||||
return fmt.Errorf("the account %s has no password yet; the operator gives it at the desk", a.Name)
|
||||
}
|
||||
return SendMail(dial, a, pw, to, subject, body, now)
|
||||
}
|
||||
}
|
||||
|
||||
func stringList(v any) []string {
|
||||
list, _ := v.([]any)
|
||||
out := make([]string, 0, len(list))
|
||||
@@ -483,7 +619,8 @@ func (b *Bundle) sendStatusTool(args map[string]any) (any, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("%s needs id, the ask %s gave", SendStatusTool, SendTool)
|
||||
}
|
||||
return ReadSendStatus(b.sends(), b.askBus(), b.lookupAccount, b.sendActor(), id, b.now())
|
||||
caller := b.caller(args)
|
||||
return ReadSendStatus(b.sends(), b.askBus(), b.queuePublish(), id, caller, b.now())
|
||||
}
|
||||
|
||||
// ---- arguments ----------------------------------------------------------------------------------------------------
|
||||
@@ -510,8 +647,126 @@ func number(args map[string]any, key string, otherwise int) (int, error) {
|
||||
return int(f), nil
|
||||
}
|
||||
|
||||
func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
// flag is 1232: a plain optional boolean filter, false (no filter) when left out.
|
||||
func flag(args map[string]any, key string) (bool, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return false, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// triBool is 1232 (mail_search's unread): nil when left out (no filter), else the given true or false.
|
||||
func triBool(args map[string]any, key string) (*bool, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is true or false", key)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// stringsArg is 1232: an optional list of strings (mail_search's from, subject-words, body-words), refusing,
|
||||
// naming key, anything that is not a list of strings, before the index is ever asked (AC16).
|
||||
func stringsArg(args map[string]any, key string) ([]string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is a list of words", key)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is a list of words", key)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// dateLayouts is 1232/AC16: the forms on-or-after and before parse, RFC3339 first, then a bare day (the whole
|
||||
// day, UTC).
|
||||
var dateLayouts = []string{time.RFC3339, "2006-01-02"}
|
||||
|
||||
// dateArg is 1232/AC16: a date that does not parse is refused naming key, before the index is ever asked.
|
||||
func dateArg(args map[string]any, key string) (time.Time, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return time.Time{}, fmt.Errorf("%s is a date", key)
|
||||
}
|
||||
for _, layout := range dateLayouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("%s is not a date (RFC3339, or YYYY-MM-DD): %q", key, s)
|
||||
}
|
||||
|
||||
// orderByNames is 1230/AC17's fixed set; order-by outside it is refused by name (AC16), before the index is
|
||||
// ever asked.
|
||||
var orderByNames = map[string]bool{"date": true, "sender": true, "subject": true, "size": true}
|
||||
|
||||
func orderByArg(args map[string]any) (string, error) {
|
||||
v, ok := args["order-by"]
|
||||
if !ok || v == nil {
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || !orderByNames[s] {
|
||||
return "", errors.New("order-by is one of date, sender, subject, size")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// orderArg is 1230: asc or desc; desc (newest, largest, ...) when left out, as the schema says (review round 1
|
||||
// finding 5: this returned ascending on nothing given, contradicting its own description); anything else is
|
||||
// refused (AC16), before the index is ever asked.
|
||||
func orderArg(args map[string]any) (bool, error) {
|
||||
v, ok := args["order"]
|
||||
if !ok || v == nil {
|
||||
return true, nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return false, errors.New("order is asc or desc")
|
||||
}
|
||||
switch s {
|
||||
case "asc":
|
||||
return false, nil
|
||||
case "desc":
|
||||
return true, nil
|
||||
default:
|
||||
return false, errors.New("order is asc or desc")
|
||||
}
|
||||
}
|
||||
|
||||
func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
|
||||
// enumText is 1232/AC17: a string argument whose schema itself names the values it accepts (order-by, order).
|
||||
func enumText(d string, values ...string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": d, "enum": values}
|
||||
}
|
||||
|
||||
func integer(d string) map[string]any { return map[string]any{"type": "integer", "description": d} }
|
||||
func boolean(d string) map[string]any { return map[string]any{"type": "boolean", "description": d} }
|
||||
func stringArray(d string) map[string]any {
|
||||
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": d}
|
||||
}
|
||||
|
||||
func schema(required []string, props map[string]any) map[string]any {
|
||||
s := map[string]any{"type": "object", "properties": props, "additionalProperties": false}
|
||||
|
||||
@@ -273,3 +273,181 @@ func TestARefusalSaysWhyTheGrantsCouldNotBeRead(t *testing.T) {
|
||||
t.Fatalf("accounts: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// 1228/AC1, 1232: with no filter at all (not even query), mail_search answers the newest mails of the granted
|
||||
// accounts, not a refusal demanding a query.
|
||||
// 1228/AC1, 1232 (review round 1 finding 7): several mails over two granted accounts and folders, one mail of a
|
||||
// third account the caller is not granted, and a limit below the granted count — the newest granted mails
|
||||
// answer, in that exact order, never the ungranted account's, even though its mail is the newest of all.
|
||||
func TestMailSearchWithNoFilterAnswersTheNewestGrantedMail(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: dir,
|
||||
Status: filepath.Join(dir, "health.json")}
|
||||
writeFile(t, env.Accounts, `{"accounts": {
|
||||
"mesh": {"kind": "imap", "address": "jschoubben@novox.be", "incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"}},
|
||||
"gmail": {"kind": "imap", "address": "me@gmail.example", "incoming": {"host": "imap.gmail.com", "port": 993, "security": "tls"}},
|
||||
"secret": {"kind": "imap", "address": "other@example.org", "incoming": {"host": "mail.example.org", "port": 993, "security": "tls"}}}}`)
|
||||
writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"], "gmail": ["triage"], "secret": ["other"]}}`)
|
||||
st := newMemStore()
|
||||
ctx := context.Background()
|
||||
at := func(h int) time.Time { return judgedAt.Add(time.Duration(h) * time.Hour) }
|
||||
for _, m := range []Message{
|
||||
{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "<m1@mesh>", Subject: "m1", Date: at(1),
|
||||
From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
{Account: "mesh", Folder: "INBOX", UID: 2, MessageID: "<m2@mesh>", Subject: "m2", Date: at(2),
|
||||
From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
{Account: "mesh", Folder: "Sent", UID: 1, MessageID: "<m3@mesh>", Subject: "m3", Date: at(3),
|
||||
From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
{Account: "gmail", Folder: "INBOX", UID: 1, MessageID: "<m4@gmail>", Subject: "m4", Date: at(4),
|
||||
From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
{Account: "gmail", Folder: "Sent", UID: 1, MessageID: "<m5@gmail>", Subject: "m5", Date: at(5),
|
||||
From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
{Account: "mesh", Folder: "Junk", UID: 1, MessageID: "<m6@mesh>", Subject: "m6", Date: at(6),
|
||||
From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
// The newest of all, but in an account triage is not granted: it must never answer.
|
||||
{Account: "secret", Folder: "INBOX", UID: 1, MessageID: "<s1@secret>", Subject: "s1", Date: at(7),
|
||||
From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
|
||||
} {
|
||||
if err := st.Add(ctx, m, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
b := &Bundle{Env: env, Index: func(context.Context) (Store, error) { return st, nil }, Now: func() time.Time { return judgedAt }}
|
||||
rt := startBundle(t, b.Tools())
|
||||
text, isErr := rt.call(SearchTool, "novox.triage", map[string]any{"limit": 3})
|
||||
if isErr {
|
||||
t.Fatalf("%v %s", isErr, text)
|
||||
}
|
||||
if strings.Contains(text, "secret") || strings.Contains(text, "s1@secret") {
|
||||
t.Fatalf("the ungranted account's mail answered: %s", text)
|
||||
}
|
||||
i6, i5, i4 := strings.Index(text, "m6@mesh"), strings.Index(text, "m5@gmail"), strings.Index(text, "m4@gmail")
|
||||
if i6 < 0 || i5 < 0 || i4 < 0 || !(i6 < i5 && i5 < i4) {
|
||||
t.Fatalf("not exactly m6, m5, m4 in that (newest-first) order: %s", text)
|
||||
}
|
||||
if strings.Contains(text, "m3@mesh") || strings.Contains(text, "m2@mesh") || strings.Contains(text, "m1@mesh") {
|
||||
t.Fatalf("limit 3 answered more than the 3 newest: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// 1232/AC16: an unknown order-by, a limit over 50, a date that does not parse, or an account not granted are
|
||||
// each refused naming the argument, and the index is never asked.
|
||||
func TestMailSearchValidatesBeforeAskingTheIndex(t *testing.T) {
|
||||
b, _, _ := aBundle(t)
|
||||
calls := 0
|
||||
index := b.Index
|
||||
b.Index = func(ctx context.Context) (Store, error) { calls++; return index(ctx) }
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
want string
|
||||
}{
|
||||
{"order-by", map[string]any{"order-by": "popularity"}, "order-by"},
|
||||
{"limit", map[string]any{"limit": 51}, "limit"},
|
||||
{"on-or-after", map[string]any{"on-or-after": "not a date"}, "on-or-after"},
|
||||
{"account", map[string]any{"account": "no-such-account"}, "no-such-account"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
text, isErr := rt.call(SearchTool, "novox.triage", c.args)
|
||||
if !isErr || !strings.Contains(text, c.want) {
|
||||
t.Fatalf("%s: %v %s", c.name, isErr, text)
|
||||
}
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("the index was asked %d time(s) before a refusal", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1232/AC17: the schema describes every filter and order-by's own form (date, sender, subject, size, each
|
||||
// ascending or descending).
|
||||
func TestMailSearchSchemaOffersEveryFilterAndOrderBy(t *testing.T) {
|
||||
b := &Bundle{}
|
||||
var input map[string]any
|
||||
for _, tool := range b.Tools() {
|
||||
if tool.Name == SearchTool {
|
||||
input = tool.Input
|
||||
}
|
||||
}
|
||||
if input == nil {
|
||||
t.Fatal("no mail_search tool")
|
||||
}
|
||||
props, _ := input["properties"].(map[string]any)
|
||||
for _, name := range searchArgs {
|
||||
if _, ok := props[name]; !ok {
|
||||
t.Fatalf("mail_search's schema does not offer %q", name)
|
||||
}
|
||||
}
|
||||
orderBy, _ := props["order-by"].(map[string]any)
|
||||
if got, _ := orderBy["enum"].([]string); !sameStrings(got, []string{"date", "sender", "subject", "size"}) {
|
||||
t.Fatalf("order-by's enum: %v", orderBy["enum"])
|
||||
}
|
||||
order, _ := props["order"].(map[string]any)
|
||||
if got, _ := order["enum"].([]string); !sameStrings(got, []string{"asc", "desc"}) {
|
||||
t.Fatalf("order's enum: %v", order["enum"])
|
||||
}
|
||||
// mail-store.search carries the very same schema (issue 1232: one schema, not two).
|
||||
var seatInput map[string]any
|
||||
for _, tool := range b.Tools() {
|
||||
if tool.Name == SeatSearchTool {
|
||||
seatInput = tool.Input
|
||||
}
|
||||
}
|
||||
seatProps, _ := seatInput["properties"].(map[string]any)
|
||||
if len(seatProps) != len(props) {
|
||||
t.Fatalf("mail-store.search's schema has %d properties, mail_search's has %d", len(seatProps), len(props))
|
||||
}
|
||||
}
|
||||
|
||||
// 1232 (the end-to-end path of the others): from, unread, order-by/order and group-by-sender, called the way a
|
||||
// caller calls them, through the MCP tool and its JSON arguments, not Go's Query directly.
|
||||
func TestMailSearchEndToEndFiltersAndGroupBySender(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: dir,
|
||||
Status: filepath.Join(dir, "health.json")}
|
||||
writeFile(t, env.Accounts, stageOne)
|
||||
writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`)
|
||||
st := newMemStore()
|
||||
ctx := context.Background()
|
||||
older := judgedAt.Add(-time.Hour)
|
||||
if err := st.Add(ctx, Message{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "<a@mesh>", Subject: "from alice",
|
||||
Date: older, From: []Person{{Address: "alice@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
|
||||
Flags: []string{}, Attachments: []AttachmentRef{}, Body: "hi"}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Add(ctx, Message{Account: "mesh", Folder: "INBOX", UID: 2, MessageID: "<b@mesh>", Subject: "from alice again",
|
||||
Date: judgedAt, From: []Person{{Address: "alice@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
|
||||
Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{}, Body: "hi again"}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &Bundle{Env: env, Index: func(context.Context) (Store, error) { return st, nil }, Now: func() time.Time { return judgedAt }}
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
unread := true
|
||||
text, isErr := rt.call(SearchTool, "novox.triage", map[string]any{"from": []any{"example.org"}, "unread": unread,
|
||||
"order-by": "date", "order": "asc"})
|
||||
if isErr || !strings.Contains(text, "a@mesh") || strings.Contains(text, "b@mesh") {
|
||||
t.Fatalf("%v %s", isErr, text)
|
||||
}
|
||||
|
||||
text, isErr = rt.call(SearchTool, "novox.triage", map[string]any{"group-by-sender": true})
|
||||
var grouped struct {
|
||||
Senders []SenderGroup `json:"senders"`
|
||||
}
|
||||
if isErr || json.Unmarshal([]byte(text), &grouped) != nil || len(grouped.Senders) != 1 ||
|
||||
grouped.Senders[0].Address != "alice@example.org" || grouped.Senders[0].Count != 2 {
|
||||
t.Fatalf("%v %s", isErr, text)
|
||||
}
|
||||
|
||||
// 1232/AC17 (review round 1 finding 5): order-by given, order left out, defaults to desc, as the schema's
|
||||
// own description promises — not asc. "from alice" is a prefix of "from alice again", so ascending would put
|
||||
// <a@mesh> first; descending puts <b@mesh> first.
|
||||
text, isErr = rt.call(SearchTool, "novox.triage", map[string]any{"order-by": "subject"})
|
||||
if isErr {
|
||||
t.Fatalf("%v %s", isErr, text)
|
||||
}
|
||||
if first, second := strings.Index(text, "b@mesh"), strings.Index(text, "a@mesh"); first < 0 || second < 0 || first > second {
|
||||
t.Fatalf("order-by with no order was not desc by default: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
module mesh-mail
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/emersion/go-message v0.18.2
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c h1:/f6f2ugss172hDJSL3EFZqb9PdfO5DKzYVfPiX2LdAM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -9,6 +11,8 @@ github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -17,6 +21,20 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -27,6 +45,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -36,14 +56,17 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -52,8 +75,10 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user