Files
mesh-lab/asks/faketelegram_test.go
T
jschoubben 3a486014fc
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery superseded: a newer head of the same pull request
Prove the operator's answers end to end before anybody uses them (hq ADR 0259)
The bus the controller composes and raises, at its commit, for one machine; the mesh's runtime serving
the router and the Telegram channel on their own credentials; a fake Bot API as the phone. It links the
operator's account with the code shown on the desk, refuses an approval in the hour after a link,
performs an approval exactly once with who and how in the warrant and the router's record, refuses a
replayed tap and a redelivered warrant, tells the operator when another account answers, drops a tap from
a group, does nothing on Decline and on expiry, offers no approval while an agent can become root where
the channel runs, and has the bus refuse every forged warrant, ask and tap. live-acceptance.sh is the same
after rollout, with a drill the operator approves on the phone.
2026-10-09 11:08:42 +02:00

262 lines
7.2 KiB
Go

package asks
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// fakeTelegram is the Bot API as the channel uses it, and nothing more: the lab's phone. It holds the updates
// the lab makes happen (a message typed, a button tapped) for the channel's long poll, and keeps every message
// the bot sends or edits, by chat, so the lab reads what the operator would see. It never reaches Telegram.
type fakeTelegram struct {
t *testing.T
server *httptest.Server
token string
mu sync.Mutex
updates []map[string]any
next int64
arrived chan struct{}
sent []tgMessage
msgID int64
taps int // answerCallbackQuery calls
deleted int
}
// tgMessage is one message the bot sent or edited: its chat, its id, its words and its buttons (label →
// callback data).
type tgMessage struct {
Chat int64
ID int64
Text string
Buttons [][2]string
Edited bool
At time.Time
}
func newFakeTelegram(t *testing.T, token string) *fakeTelegram {
f := &fakeTelegram{t: t, token: token, next: 1, arrived: make(chan struct{}, 1)}
f.server = httptest.NewServer(http.HandlerFunc(f.serve))
t.Cleanup(f.server.Close)
return f
}
func (f *fakeTelegram) URL() string { return f.server.URL }
func (f *fakeTelegram) serve(w http.ResponseWriter, r *http.Request) {
prefix := "/bot" + f.token + "/"
if !strings.HasPrefix(r.URL.Path, prefix) {
// A wrong token is what the real API answers 401 to.
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error_code": 401, "description": "Unauthorized"})
return
}
method := strings.TrimPrefix(r.URL.Path, prefix)
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
ok := func(result any) { _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result}) }
switch method {
case "getMe":
ok(map[string]any{"id": 1000, "is_bot": true, "first_name": "Lab", "username": "lab_mesh_bot"})
case "getUpdates":
offset := int64(num(body["offset"]))
wait := time.Duration(num(body["timeout"])) * time.Second
if wait > 2*time.Second {
wait = 2 * time.Second // the lab's phone answers quickly; the channel polls again at once
}
deadline := time.Now().Add(wait)
for {
f.mu.Lock()
var out []map[string]any
for _, u := range f.updates {
if int64(num(u["update_id"])) >= offset {
out = append(out, u)
}
}
f.mu.Unlock()
if len(out) > 0 {
f.t.Logf("phone: getUpdates from %d hands %d update(s)", offset, len(out))
}
if len(out) > 0 || time.Now().After(deadline) {
ok(orEmpty(out))
return
}
select {
case <-f.arrived:
case <-time.After(time.Until(deadline)):
}
}
case "sendMessage", "editMessageText":
chat := int64(num(body["chat_id"]))
text, _ := body["text"].(string)
m := tgMessage{Chat: chat, Text: text, Buttons: buttonsOf(body["reply_markup"]), At: time.Now()}
f.mu.Lock()
if method == "sendMessage" {
f.msgID++
m.ID = f.msgID
} else {
m.ID, m.Edited = int64(num(body["message_id"])), true
}
f.sent = append(f.sent, m)
f.mu.Unlock()
f.t.Logf("phone: %s to %d (#%d): %q %v", method, chat, m.ID, firstLine(text), m.Buttons)
ok(map[string]any{"message_id": m.ID, "chat": map[string]any{"id": chat, "type": "private"}, "text": text})
case "answerCallbackQuery":
f.mu.Lock()
f.taps++
f.mu.Unlock()
ok(true)
case "deleteMessage":
f.mu.Lock()
f.deleted++
f.mu.Unlock()
ok(true)
default:
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error_code": 404, "description": "Not Found: " + method})
}
}
// push makes one thing happen on the phone, for the channel's next poll.
func (f *fakeTelegram) push(u map[string]any) {
f.mu.Lock()
u["update_id"] = f.next
f.next++
f.updates = append(f.updates, u)
f.mu.Unlock()
select {
case f.arrived <- struct{}{}:
default:
}
}
// typed is a message an account writes in a chat with the bot; private when the chat is the account's own.
func (f *fakeTelegram) typed(from int64, chat int64, chatType, text string) {
f.push(map[string]any{"message": map[string]any{"message_id": 9000 + f.nextID(), "text": text,
"from": map[string]any{"id": from, "is_bot": false, "first_name": fmt.Sprintf("Account %d", from)},
"chat": map[string]any{"id": chat, "type": chatType}}})
}
// tapped is a button tapped on a message the bot sent, carrying the button's data as the service gives it.
func (f *fakeTelegram) tapped(from int64, chat int64, chatType string, onMessage int64, data string) {
f.push(map[string]any{"callback_query": map[string]any{"id": fmt.Sprintf("cb-%d", f.nextID()), "data": data,
"from": map[string]any{"id": from, "is_bot": false, "first_name": fmt.Sprintf("Account %d", from)},
"message": map[string]any{"message_id": onMessage, "chat": map[string]any{"id": chat, "type": chatType}}}})
}
func (f *fakeTelegram) nextID() int64 {
f.mu.Lock()
defer f.mu.Unlock()
f.msgID++
return f.msgID
}
// waitFor waits for a message to a chat that satisfies match, sent after since.
func (f *fakeTelegram) waitFor(what string, chat int64, since time.Time, within time.Duration, match func(tgMessage) bool) tgMessage {
f.t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
f.mu.Lock()
for _, m := range f.sent {
if m.Chat == chat && !m.At.Before(since) && match(m) {
f.mu.Unlock()
return m
}
}
f.mu.Unlock()
time.Sleep(100 * time.Millisecond)
}
f.t.Fatalf("the phone never showed %s (chat %d) within %s; it showed:\n%s", what, chat, within, f.transcript())
return tgMessage{}
}
// since is every message to a chat sent at or after a time.
func (f *fakeTelegram) since(chat int64, at time.Time) []tgMessage {
f.mu.Lock()
defer f.mu.Unlock()
var out []tgMessage
for _, m := range f.sent {
if m.Chat == chat && !m.At.Before(at) {
out = append(out, m)
}
}
return out
}
func (f *fakeTelegram) transcript() string {
f.mu.Lock()
defer f.mu.Unlock()
var b strings.Builder
for _, m := range f.sent {
fmt.Fprintf(&b, " %s to %d #%d edited=%v %q %v\n", m.At.Format("15:04:05.000"), m.Chat, m.ID, m.Edited,
firstLine(m.Text), m.Buttons)
}
return b.String()
}
// button is the data of the button labelled so on a message.
func button(m tgMessage, label string) string {
for _, b := range m.Buttons {
if b[0] == label {
return b[1]
}
}
return ""
}
func labels(m tgMessage) []string {
var out []string
for _, b := range m.Buttons {
out = append(out, b[0])
}
return out
}
func buttonsOf(markup any) [][2]string {
var out [][2]string
m, _ := markup.(map[string]any)
rows, _ := m["inline_keyboard"].([]any)
for _, row := range rows {
cells, _ := row.([]any)
for _, c := range cells {
cell, _ := c.(map[string]any)
text, _ := cell["text"].(string)
data, _ := cell["callback_data"].(string)
out = append(out, [2]string{text, data})
}
}
return out
}
func num(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int64:
return float64(n)
case int:
return float64(n)
}
return 0
}
func orEmpty(in []map[string]any) []map[string]any {
if in == nil {
return []map[string]any{}
}
return in
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i] + " …"
}
return s
}