A refusal waiting for its request parsed it into a 64 KiB reader for up to 5 s, on a socket every account may open. It now drains at most 4 KiB through a small buffer for at most 1 s and never past the accept-time deadline, only 32 wait at once, and an unknown account is refused before it takes a slot. The busy tests wait for the first line with a timeout and say what came of it (novox/hq issue 434).
535 lines
20 KiB
Go
535 lines
20 KiB
Go
package meshcli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/cli"
|
|
)
|
|
|
|
// serving starts Serve on a socket of the test's own, and stops it when the test ends.
|
|
func serving(t *testing.T, forward Forward) string {
|
|
t.Helper()
|
|
socket := filepath.Join(t.TempDir(), "run", "cli.sock")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- Serve(ctx, socket, forward, func(string) {}) }()
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
if err := <-done; err != nil {
|
|
t.Errorf("Serve ended with %v", err)
|
|
}
|
|
})
|
|
for i := 0; i < 200; i++ {
|
|
if _, err := os.Stat(socket); err == nil {
|
|
return socket
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatal("the socket never appeared")
|
|
return ""
|
|
}
|
|
|
|
// The account sent is the one the kernel gives for the connecting process, and the answer comes back as it was.
|
|
func TestTheAccountAskingIsTheKernelsAndTheAnswerComesBack(t *testing.T) {
|
|
var got Asked
|
|
socket := serving(t, func(_ context.Context, asked Asked) cli.Answer {
|
|
got = asked
|
|
return cli.Answer{Stdout: []byte("done\n"), Exit: 0, Terminal: true, Why: "the terminal"}
|
|
})
|
|
a, err := cli.Ask(context.Background(), socket, []string{"settings", "show", "zsh"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(a.Stdout) != "done\n" || !a.Terminal || a.Refused != "" {
|
|
t.Fatalf("the answer came back as %+v", a)
|
|
}
|
|
me, err := user.Current()
|
|
if err != nil {
|
|
t.Skipf("this machine cannot name the test's account: %v", err)
|
|
}
|
|
if int(got.UID) != os.Getuid() || got.Account != me.Username {
|
|
t.Fatalf("asked as %d %q, and the kernel says %d %q", got.UID, got.Account, os.Getuid(), me.Username)
|
|
}
|
|
if strings.Join(got.Line, " ") != "settings show zsh" {
|
|
t.Fatalf("the line was sent as %q", got.Line)
|
|
}
|
|
if info, err := os.Stat(socket); err != nil || info.Mode().Perm() != 0o666 {
|
|
t.Fatalf("the socket is not open to every account: %v %v", info.Mode(), err)
|
|
}
|
|
}
|
|
|
|
// Credentials the kernel did not give are no account: refused, and nothing is asked.
|
|
func TestAConnectionWhoseAccountCannotBeReadIsNeverAskedAsAnyone(t *testing.T) {
|
|
before := peerOf
|
|
peerOf = func(*net.UnixConn) (uint32, int32, error) { return 0, 0, errors.New("no credentials") }
|
|
t.Cleanup(func() { peerOf = before })
|
|
asked := false
|
|
socket := serving(t, func(context.Context, Asked) cli.Answer {
|
|
asked = true
|
|
return cli.Answer{}
|
|
})
|
|
a, err := cli.Ask(context.Background(), socket, []string{"status"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if asked || a.Refused == "" || a.Exit == 0 {
|
|
t.Fatalf("a connection with no account was asked (%v) or not refused: %+v", asked, a)
|
|
}
|
|
}
|
|
|
|
// A request over the bound is refused and never asked.
|
|
func TestARequestOverTheBoundIsRefused(t *testing.T) {
|
|
asked := false
|
|
socket := serving(t, func(context.Context, Asked) cli.Answer {
|
|
asked = true
|
|
return cli.Answer{}
|
|
})
|
|
a, err := cli.Ask(context.Background(), socket, []string{strings.Repeat("x", cli.MaxRequest)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if asked || a.Refused == "" {
|
|
t.Fatalf("a request over the bound was asked (%v): %+v", asked, a)
|
|
}
|
|
}
|
|
|
|
// A file at the socket's path that is not a socket is never removed.
|
|
func TestAFileThatIsNotASocketIsKept(t *testing.T) {
|
|
dir := t.TempDir()
|
|
socket := filepath.Join(dir, "cli.sock")
|
|
if err := os.WriteFile(socket, []byte("mine"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := Serve(context.Background(), socket, nil, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "not a socket") {
|
|
t.Fatalf("a file at the socket's path was not refused: %v", err)
|
|
}
|
|
if body, _ := os.ReadFile(socket); string(body) != "mine" {
|
|
t.Fatal("the file at the socket's path was touched")
|
|
}
|
|
}
|
|
|
|
// The request the controller reads holds these field names; the controller's side holds the same list
|
|
// (mesh-controller, the mesh-cli request's test).
|
|
func TestTheRequestToTheControllerKeepsItsFieldNames(t *testing.T) {
|
|
body, err := json.Marshal(Asked{Line: []string{"status"}, Account: "a", UID: 1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, _ = json.Marshal(Asked{Line: []string{"status"}, Account: "a", UID: 1, Session: "session-3.scope"})
|
|
if got := keysOf(t, body); got != "account line session uid" {
|
|
t.Fatalf("the request's fields are %q", got)
|
|
}
|
|
body, _ = json.Marshal(Asked{Line: []string{"status"}, Account: "a", UID: 1})
|
|
if got := keysOf(t, body); got != "account line uid" {
|
|
t.Fatalf("the request's fields are %q", got)
|
|
}
|
|
body, _ = json.Marshal(Asked{Line: []string{"secret"}, Account: "a", UID: 1, Stdin: []byte("x")})
|
|
if got := keysOf(t, body); got != "account line stdin uid" {
|
|
t.Fatalf("a request with standard input has the fields %q", got)
|
|
}
|
|
body, _ = json.Marshal(Asked{Follow: "call-1", Account: "a", UID: 1})
|
|
if got := keysOf(t, body); got != "account follow uid" {
|
|
t.Fatalf("a follow's fields are %q", got)
|
|
}
|
|
// What the controller answers as a line's result, and as a running line's: the same names its side holds.
|
|
body, err = json.Marshal(cli.Answer{Stdout: []byte("o"), Stderr: []byte("e"), Exit: 1, Terminal: true, Why: "w",
|
|
Refused: "r", Cut: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := keysOf(t, body); got != "cut exit refused stderr stdout terminal why" {
|
|
t.Fatalf("the answer's fields are %q", got)
|
|
}
|
|
body, _ = json.Marshal(runningResult{Running: true, Call: "call-1"})
|
|
if got := keysOf(t, body); got != "call running" {
|
|
t.Fatalf("a running answer's fields are %q", got)
|
|
}
|
|
}
|
|
|
|
func keysOf(t *testing.T, body []byte) string {
|
|
t.Helper()
|
|
var m map[string]any
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var keys []string
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return strings.Join(keys, " ")
|
|
}
|
|
|
|
// holdALine asks one line and returns once the engine is asking it, so it holds its account's slot. A first line
|
|
// that ends or never arrives fails the test at once, with what came of it, instead of hanging the suite (novox/hq
|
|
// issue 434).
|
|
func holdALine(t *testing.T, socket string, started <-chan struct{}) {
|
|
t.Helper()
|
|
first := make(chan error, 1)
|
|
go func() {
|
|
_, err := cli.Ask(context.Background(), socket, []string{"push"})
|
|
first <- err
|
|
}()
|
|
select {
|
|
case <-started:
|
|
case err := <-first:
|
|
t.Fatalf("the first line ended before it was asked: %v", err)
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the first line was never asked")
|
|
}
|
|
}
|
|
|
|
// **One account cannot hold the socket** (review of ADR 0272): its lines beyond its own bound are answered busy at
|
|
// once, nothing is asked, and the engine goes on accepting.
|
|
func TestOneAccountsLinesBeyondItsBoundAreAnsweredBusy(t *testing.T) {
|
|
was := perAccount
|
|
perAccount = 1
|
|
t.Cleanup(func() { perAccount = was })
|
|
release := make(chan struct{})
|
|
started := make(chan struct{}, 2)
|
|
socket := serving(t, func(context.Context, Asked) cli.Answer {
|
|
started <- struct{}{}
|
|
<-release
|
|
return cli.Answer{}
|
|
})
|
|
// Released before the engine is stopped: cleanups run last first, and the engine waits for its lines.
|
|
t.Cleanup(func() { close(release) })
|
|
holdALine(t, socket, started)
|
|
type asked struct {
|
|
a cli.Answer
|
|
err error
|
|
}
|
|
done := make(chan asked, 1)
|
|
go func() {
|
|
a, err := cli.Ask(context.Background(), socket, []string{"status"})
|
|
done <- asked{a, err}
|
|
}()
|
|
select {
|
|
case got := <-done:
|
|
// An error is a busy line the client never read (novox/hq issue 434), not an answer.
|
|
if got.err != nil || !strings.Contains(got.a.Refused, "busy") {
|
|
t.Fatalf("a second line from the same account reads %+v (%v)", got.a, got.err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("a second line from the same account waited instead of being answered busy")
|
|
}
|
|
if len(started) != 0 {
|
|
t.Fatal("the busy line was asked")
|
|
}
|
|
}
|
|
|
|
// **A refusal waits for the request it answers** (novox/hq issue 434): the client writes its line first and reads
|
|
// the answer after. An engine that answered busy and closed before that write landed broke the client's write, and
|
|
// the refusal was never read — under the whole suite's load the busy line read as an error. Here the client
|
|
// connects and holds its line back: no answer may come before the line, and the line, once written, is answered
|
|
// busy. Ordered by the client's own write, so it holds however slow the machine is.
|
|
func TestARefusalWaitsForTheRequestItAnswers(t *testing.T) {
|
|
was, wasWithin := perAccount, refusedWithin
|
|
perAccount, refusedWithin = 1, time.Minute
|
|
t.Cleanup(func() { perAccount, refusedWithin = was, wasWithin })
|
|
release := make(chan struct{})
|
|
started := make(chan struct{}, 2)
|
|
socket := serving(t, func(context.Context, Asked) cli.Answer {
|
|
started <- struct{}{}
|
|
<-release
|
|
return cli.Answer{}
|
|
})
|
|
t.Cleanup(func() { close(release) })
|
|
holdALine(t, socket, started)
|
|
conn, err := net.Dial("unix", socket)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
// Nothing is written yet: an engine that answers now answers before the request. The wait is far inside
|
|
// refusedWithin, so an engine that waits is still waiting when it ends.
|
|
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
|
var b [1]byte
|
|
if n, err := conn.Read(b[:]); n > 0 || !errors.Is(err, os.ErrDeadlineExceeded) {
|
|
t.Fatalf("the engine answered before the request was written (%d bytes, %v)", n, err)
|
|
}
|
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
|
if err := cli.WriteRequest(conn, cli.Request{Line: []string{"status"}}); err != nil {
|
|
t.Fatalf("the request could not be written: %v", err)
|
|
}
|
|
a, err := cli.ReadAnswer(conn)
|
|
if err != nil || !strings.Contains(a.Refused, "busy") {
|
|
t.Fatalf("a line written after the engine chose busy reads %+v (%v)", a, err)
|
|
}
|
|
if len(started) != 0 {
|
|
t.Fatal("the busy line was asked")
|
|
}
|
|
}
|
|
|
|
// **Refusals that wait are bounded** (novox/hq issue 434, review of ADR 0272): with refusalsWaiting already full, a
|
|
// further refused line is answered at once, without waiting for its request.
|
|
func TestARefusalOverTheWaitingBoundIsAnsweredAtOnce(t *testing.T) {
|
|
was, wasWithin, wasWaiting := perAccount, refusedWithin, refusalsWaiting
|
|
perAccount, refusedWithin = 1, time.Minute
|
|
refusalsWaiting = make(chan struct{}, 1)
|
|
refusalsWaiting <- struct{}{}
|
|
t.Cleanup(func() { perAccount, refusedWithin, refusalsWaiting = was, wasWithin, wasWaiting })
|
|
release := make(chan struct{})
|
|
started := make(chan struct{}, 2)
|
|
socket := serving(t, func(context.Context, Asked) cli.Answer {
|
|
started <- struct{}{}
|
|
<-release
|
|
return cli.Answer{}
|
|
})
|
|
t.Cleanup(func() { close(release) })
|
|
holdALine(t, socket, started)
|
|
conn, err := net.Dial("unix", socket)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
// Nothing is written: a refusal that waited would wait refusedWithin, a minute, for it.
|
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
|
a, err := cli.ReadAnswer(conn)
|
|
if err != nil || !strings.Contains(a.Refused, "busy") {
|
|
t.Fatalf("a refusal over the waiting bound reads %+v (%v)", a, err)
|
|
}
|
|
}
|
|
|
|
// countingReader is an endless request with no line end, counting what was read of it.
|
|
type countingReader struct{ read int }
|
|
|
|
func (c *countingReader) Read(p []byte) (int, error) {
|
|
for i := range p {
|
|
p[i] = 'x'
|
|
}
|
|
c.read += len(p)
|
|
return len(p), nil
|
|
}
|
|
|
|
// **A refused request is drained, not kept** (novox/hq issue 434): what a refused client writes is read up to
|
|
// drainCap bytes and no further, however much it sends.
|
|
func TestARefusedRequestIsDrainedNoFurtherThanItsCap(t *testing.T) {
|
|
r := &countingReader{}
|
|
drain(r)
|
|
if r.read != drainCap {
|
|
t.Fatalf("drained %d bytes of an endless request; the cap is %d", r.read, drainCap)
|
|
}
|
|
after := &countingReader{}
|
|
drain(io.MultiReader(strings.NewReader("{\"line\":[\"status\"]}\n"), after))
|
|
if after.read != 0 {
|
|
t.Fatalf("draining read %d bytes past the end of the request line", after.read)
|
|
}
|
|
}
|
|
|
|
// **A process in another namespace is not this machine's account** (review of ADR 0272): a container running with
|
|
// the operator's user id shares the id and not the machine; refused, and nothing is asked.
|
|
func TestAPeerInAnotherNamespaceIsRefused(t *testing.T) {
|
|
before := sameNamespaces
|
|
sameNamespaces = func(pid int32) error { return errors.New("its mount namespace is not the engine's") }
|
|
t.Cleanup(func() { sameNamespaces = before })
|
|
asked := false
|
|
socket := serving(t, func(context.Context, Asked) cli.Answer {
|
|
asked = true
|
|
return cli.Answer{}
|
|
})
|
|
a, err := cli.Ask(context.Background(), socket, []string{"status"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if asked || !strings.Contains(a.Refused, "namespace") {
|
|
t.Fatalf("a peer in another namespace was asked (%v): %+v", asked, a)
|
|
}
|
|
}
|
|
|
|
// **The session a line comes from is the kernel's word, and only a login session counts** (review of ADR 0272): a
|
|
// service running as the operator — the tool runner, a user unit — is not a person at a terminal. The engine reads
|
|
// the peer's cgroup and sends the login session's scope, or nothing; the controller makes no other terminal.
|
|
func TestTheLoginSessionAskingIsReadFromTheKernel(t *testing.T) {
|
|
for _, c := range []struct{ cgroup, session string }{
|
|
{"0::/user.slice/user-1000.slice/session-3.scope\n", "session-3.scope"},
|
|
{"0::/user.slice/user-1000.slice/session-c1.scope\n", "session-c1.scope"},
|
|
{"0::/user.slice/user-1000.slice/user@1000.service/app.slice/tmux.scope\n", ""},
|
|
{"0::/system.slice/node-tools.service\n", ""},
|
|
{"0::/user.slice/user-1001.slice/session-3.scope\n", ""}, // another account's session
|
|
{"12:pids:/user.slice\n0::/user.slice/user-1000.slice/session-9.scope/sub\n", ""},
|
|
// Below a session scope, in a scope of its own: only the check that the scope is the account slice's direct
|
|
// child refuses it.
|
|
{"0::/user.slice/user-1000.slice/session-9.scope/x.scope\n", ""},
|
|
{"", ""},
|
|
} {
|
|
if got := loginSessionIn(c.cgroup, 1000); got != c.session {
|
|
t.Errorf("%q read as %q, want %q", c.cgroup, got, c.session)
|
|
}
|
|
}
|
|
// And it is sent: the test process's own, whatever it is, as the kernel says it.
|
|
var got Asked
|
|
socket := serving(t, func(_ context.Context, asked Asked) cli.Answer {
|
|
got = asked
|
|
return cli.Answer{}
|
|
})
|
|
if _, err := cli.Ask(context.Background(), socket, []string{"status"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := os.ReadFile("/proc/self/cgroup")
|
|
if want := userSessionIn(string(raw), uint32(os.Getuid())); got.Session != want {
|
|
t.Fatalf("sent session %q, the kernel says %q", got.Session, want)
|
|
}
|
|
}
|
|
|
|
// **A login session is a person's: systemd's own record says so** (review of ADR 0272): the scope the cgroup names
|
|
// must be a session logind keeps for this very account, of class `user` — not a greeter's, not the user manager's,
|
|
// not one whose record names another account — and read at accept, with the namespaces, before anything else.
|
|
func TestALoginSessionIsAUsersByItsOwnRecord(t *testing.T) {
|
|
dir := t.TempDir()
|
|
was := sessionsDir
|
|
sessionsDir = dir
|
|
t.Cleanup(func() { sessionsDir = was })
|
|
write := func(id, body string) {
|
|
if err := os.WriteFile(filepath.Join(dir, id), []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("9", "UID=1000\nUSER=op\nCLASS=user\nSCOPE=session-9.scope\nACTIVE=1\n")
|
|
write("3", "UID=1000\nCLASS=greeter\nSCOPE=session-3.scope\n")
|
|
write("5", "UID=1000\nCLASS=user\nSCOPE=session-6.scope\n")
|
|
// The account the record names is not the slice's: only the record's UID check refuses it.
|
|
write("8", "UID=1001\nCLASS=user\nSCOPE=session-8.scope\n")
|
|
// A name with a dot, held as a record that would pass: only the check of the id's characters refuses it.
|
|
write("a.b", "UID=1000\nCLASS=user\nSCOPE=session-a.b.scope\n")
|
|
// A key said twice: the first is the record's word (systemd writes each once); a later one is not read.
|
|
write("11", "UID=1001\nCLASS=user\nSCOPE=session-11.scope\nUID=1000\n")
|
|
write("12", "UID=1000\nCLASS=user\nSCOPE=session-12.scope\nCLASS=greeter\nUID=1001\n")
|
|
for _, c := range []struct {
|
|
cgroup string
|
|
uid uint32
|
|
session string
|
|
}{
|
|
{"0::/user.slice/user-1000.slice/session-9.scope\n", 1000, "session-9.scope"},
|
|
{"0::/user.slice/user-1000.slice/x.scope\n", 1000, ""},
|
|
{"0::/user.slice/user-1000.slice/session-9.scope\n", 1001, ""}, // asked as another uid than the slice's
|
|
{"0::/user.slice/user-1000.slice/session-8.scope\n", 1000, ""}, // the record names uid 1001
|
|
{"0::/user.slice/user-1000.slice/session-3.scope\n", 1000, ""}, // a greeter's
|
|
{"0::/user.slice/user-1000.slice/session-5.scope\n", 1000, ""}, // a record naming another scope
|
|
{"0::/user.slice/user-1000.slice/session-7.scope\n", 1000, ""}, // no record at all
|
|
{"0::/user.slice/user-1000.slice/session-a.b.scope\n", 1000, ""},
|
|
{"0::/user.slice/user-1000.slice/session-...scope\n", 1000, ""},
|
|
{"0::/user.slice/user-1000.slice/session-11.scope\n", 1000, ""}, // first UID is 1001
|
|
{"0::/user.slice/user-1000.slice/session-12.scope\n", 1000, "session-12.scope"}, // first UID and CLASS stand
|
|
} {
|
|
if got := userSessionIn(c.cgroup, c.uid); got != c.session {
|
|
t.Errorf("%q as uid %d read as %q, want %q", c.cgroup, c.uid, got, c.session)
|
|
}
|
|
}
|
|
}
|
|
|
|
// **The session is read at accept, with the namespaces, before the line** (review of ADR 0272): the peer is judged as
|
|
// it was when it connected. The client sends its line only once the session has been read; were the session read
|
|
// after the line — in the forward, say — the line would never be sent and the test fails.
|
|
func TestTheSessionIsReadBeforeTheLine(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var order []string
|
|
note := func(s string) { mu.Lock(); order = append(order, s); mu.Unlock() }
|
|
read := make(chan struct{}, 1)
|
|
beforeNS, beforeSession := sameNamespaces, sessionOf
|
|
sameNamespaces = func(int32) error { note("namespaces"); return nil }
|
|
sessionOf = func(int32, uint32) string { note("session"); read <- struct{}{}; return "session-1.scope" }
|
|
t.Cleanup(func() { sameNamespaces, sessionOf = beforeNS, beforeSession })
|
|
var got Asked
|
|
socket := serving(t, func(_ context.Context, asked Asked) cli.Answer {
|
|
note("forward")
|
|
got = asked
|
|
return cli.Answer{}
|
|
})
|
|
conn, err := net.Dial("unix", socket)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
select {
|
|
case <-read:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("the session was not read before the line was sent")
|
|
}
|
|
note("line sent")
|
|
if err := cli.WriteRequest(conn, cli.Request{Line: []string{"status"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
|
if _, err := cli.ReadAnswer(conn); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if strings.Join(order, ",") != "namespaces,session,line sent,forward" || got.Session != "session-1.scope" {
|
|
t.Fatalf("read in the order %v, sent %q", order, got.Session)
|
|
}
|
|
}
|
|
|
|
// mesh-cli's standard input reaches the controller with its line, whole, and only with the line: a follow of the
|
|
// same call carries none, and the engine says it nowhere (novox/hq ADR 0259 §10, ADR 0272).
|
|
func TestStandardInputGoesToTheControllerWithTheLineAndNowhereElse(t *testing.T) {
|
|
secret := []byte("123456789:the-bot-token-given-at-the-terminal\n")
|
|
var said []string
|
|
var mu sync.Mutex
|
|
var asked [][]byte
|
|
ask := func(_ context.Context, body []byte) ([]byte, error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
asked = append(asked, append([]byte(nil), body...))
|
|
if len(asked) == 1 {
|
|
return []byte(`{"result":{"running":true,"call":"call-7"}}`), nil
|
|
}
|
|
return []byte(`{"result":{"exit":0,"terminal":true}}`), nil
|
|
}
|
|
was := followEvery
|
|
followEvery = time.Millisecond
|
|
t.Cleanup(func() { followEvery = was })
|
|
socket := filepath.Join(t.TempDir(), "run", "cli.sock")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- Serve(ctx, socket, func(ctx context.Context, a Asked) cli.Answer { return AskTheController(ctx, ask, a) },
|
|
func(s string) { mu.Lock(); said = append(said, s); mu.Unlock() })
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
for i := 0; i < 200; i++ {
|
|
if _, err := os.Stat(socket); err == nil {
|
|
break
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
a, err := cli.AskWith(context.Background(), socket, cli.Request{Line: []string{"secret", "accept", "anchor",
|
|
"telegram", "telegram-token", "--from", "-"}, Stdin: secret})
|
|
if err != nil || a.Exit != 0 {
|
|
t.Fatalf("%v %+v", err, a)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(asked) != 2 {
|
|
t.Fatalf("asked %d times, want the line and one follow", len(asked))
|
|
}
|
|
var first, follow Asked
|
|
_ = json.Unmarshal(asked[0], &first)
|
|
_ = json.Unmarshal(asked[1], &follow)
|
|
if string(first.Stdin) != string(secret) {
|
|
t.Fatalf("the controller was not given the standard input whole: %q", first.Stdin)
|
|
}
|
|
if len(follow.Stdin) != 0 || strings.Contains(string(asked[1]), "stdin") {
|
|
t.Fatalf("a follow carried the standard input again: %s", asked[1])
|
|
}
|
|
for _, s := range said {
|
|
if strings.Contains(s, "bot-token") {
|
|
t.Fatalf("the engine said the standard input: %s", s)
|
|
}
|
|
}
|
|
}
|