claude-code: write the agent's home as its own account where it has one
On the control node agents are to run as an account that cannot become root (hq ADR 0266). The runtime keeps running as the operator, so the token, the identity and the home-scope items go to the agent's home through a helper started as that account; the licence and the account file follow the move, what was placed in the operator's home goes once the agent holds the licence, and the guard refuses every tool of a session running as anyone else, recording to the journal. An API key is taken only from the agent's home and only when it is one, here and in the licence manager, so the tool cannot carry off the runtime's credential. Unchanged where no agent account is named.
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
package main
|
||||
|
||||
// Where the agent runs as an account of its own (novox/hq ADR 0266): the home touched through the helper, the
|
||||
// licence that follows the account, what goes from the operator's home, and the guard that refuses a session
|
||||
// running as anyone else.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// inProcess is a helper served in this process over pipes, counting what it was asked; kill ends it, as a
|
||||
// helper that died would.
|
||||
type inProcess struct {
|
||||
asked atomic.Int64
|
||||
mu sync.Mutex
|
||||
kill func()
|
||||
}
|
||||
|
||||
func (c *inProcess) die() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.kill()
|
||||
}
|
||||
|
||||
func helperInProcess(account string, counted *inProcess) *Helper {
|
||||
return &Helper{Account: account, Start: func() (io.WriteCloser, io.Reader, func(), error) {
|
||||
reqR, reqW := io.Pipe()
|
||||
ansR, ansW := io.Pipe()
|
||||
counting := &countingReader{r: reqR, n: &counted.asked}
|
||||
go func() { _ = ServeHelper(counting, ansW); _ = ansW.Close() }()
|
||||
counted.mu.Lock()
|
||||
counted.kill = func() { _ = reqR.Close(); _ = ansW.Close() }
|
||||
counted.mu.Unlock()
|
||||
return reqW, ansR, func() { _ = reqW.Close() }, nil
|
||||
}}
|
||||
}
|
||||
|
||||
type countingReader struct {
|
||||
r io.Reader
|
||||
n *atomic.Int64
|
||||
}
|
||||
|
||||
func (c *countingReader) Read(p []byte) (int, error) {
|
||||
n, err := c.r.Read(p)
|
||||
c.n.Add(int64(bytes.Count(p[:n], []byte("\n"))))
|
||||
return n, err
|
||||
}
|
||||
|
||||
func agentFiles(t *testing.T) (*Files, string, *inProcess) {
|
||||
t.Helper()
|
||||
home := filepath.Join(t.TempDir(), "agent")
|
||||
if err := os.MkdirAll(home, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counted := &inProcess{}
|
||||
return &Files{Home: home, Account: "agent", Helper: helperInProcess("agent", counted)}, home, counted
|
||||
}
|
||||
|
||||
func TestTheAgentsHomeIsTouchedThroughTheHelper(t *testing.T) {
|
||||
f, home, counted := agentFiles(t)
|
||||
file := filepath.Join(home, ".claude", "skills", "x", "SKILL.md")
|
||||
if err := f.MkdirAll(filepath.Dir(file), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.WriteFile(file, []byte("hello"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := f.ReadFile(file); err != nil || string(got) != "hello" {
|
||||
t.Fatalf("read back %q, %v", got, err)
|
||||
}
|
||||
info, err := f.Stat(file)
|
||||
if err != nil || info.Size() != 5 || info.Mode().Perm() != 0o600 || info.IsDir() {
|
||||
t.Fatalf("stat: %v %v", info, err)
|
||||
}
|
||||
if err := f.Chmod(file, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info, _ := f.Lstat(file); info.Mode().Perm() != 0o644 {
|
||||
t.Fatalf("chmod did not take: %v", info.Mode())
|
||||
}
|
||||
if err := f.CreateExclusive(file, []byte("again"), 0o600); !errors.Is(err, fs.ErrExist) {
|
||||
t.Fatalf("a file that is there is said to exist, got %v", err)
|
||||
}
|
||||
if _, err := f.ReadFile(filepath.Join(home, "absent")); !os.IsNotExist(err) {
|
||||
t.Fatalf("an absent file is said not to exist, as the filesystem says it, got %v", err)
|
||||
}
|
||||
entries, err := f.ReadDir(filepath.Join(home, ".claude", "skills"))
|
||||
if err != nil || len(entries) != 1 || entries[0].Name() != "x" || !entries[0].IsDir() {
|
||||
t.Fatalf("readdir: %v %v", entries, err)
|
||||
}
|
||||
var walked []string
|
||||
if err := f.WalkDir(filepath.Join(home, ".claude"), func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(home, path)
|
||||
walked = append(walked, rel)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(walked, ",") != ".claude,.claude/skills,.claude/skills/x,.claude/skills/x/SKILL.md" {
|
||||
t.Fatalf("walked %v", walked)
|
||||
}
|
||||
moved := filepath.Join(home, "moved")
|
||||
if err := f.Rename(file, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Remove(moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.RemoveAll(filepath.Join(home, ".claude")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".claude")); !os.IsNotExist(err) {
|
||||
t.Fatal("remove-all left the directory")
|
||||
}
|
||||
if counted.asked.Load() < 12 {
|
||||
t.Fatalf("every operation under the home goes through the helper; it was asked %d times", counted.asked.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPathOutsideTheAgentsHomeIsNeverTheHelpers(t *testing.T) {
|
||||
f, home, counted := agentFiles(t)
|
||||
state := t.TempDir()
|
||||
if err := f.WriteFile(filepath.Join(state, "x"), []byte("y"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.ReadFile(filepath.Join(state, "x")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A sibling whose name begins with the home's is not under it.
|
||||
if f.through(home+"-other/x") || !f.through(home) || !f.through(home+"/.claude.json") {
|
||||
t.Fatal("the home is the home and nothing beside it")
|
||||
}
|
||||
if err := f.Rename(filepath.Join(state, "x"), filepath.Join(home, "x")); err == nil {
|
||||
t.Fatal("a move between the home and elsewhere is refused: no one account may do both")
|
||||
}
|
||||
if counted.asked.Load() != 0 {
|
||||
t.Fatalf("the helper was asked %d times for paths that are not the agent's", counted.asked.Load())
|
||||
}
|
||||
if (&Files{}).through(home) {
|
||||
t.Fatal("with no agent account of its own, nothing goes through a helper")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHelperThatDiedIsStartedAgain(t *testing.T) {
|
||||
f, home, counted := agentFiles(t)
|
||||
if err := f.WriteFile(filepath.Join(home, "a"), []byte("1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counted.die()
|
||||
if got, err := f.ReadFile(filepath.Join(home, "a")); err != nil || string(got) != "1" {
|
||||
t.Fatalf("after the helper died the next request is answered by a new one: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHelperRefusesARelativePathAndAnUnknownOperation(t *testing.T) {
|
||||
if a := serveOne(Op{Op: "read", Path: "relative"}); a.Err == "" {
|
||||
t.Fatal("a relative path is refused")
|
||||
}
|
||||
if a := serveOne(Op{Op: "exec", Path: "/"}); !strings.Contains(a.Err, "no such operation") {
|
||||
t.Fatalf("only the filesystem's operations are served, got %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the paths --------------------------------------------------------------------------------------------
|
||||
|
||||
func envOf(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||
|
||||
func TestTheAgentsAccountAndHomeComeFromTheRuntime(t *testing.T) {
|
||||
base := map[string]string{"MESH_CLAUDE_CODE_STATE": "/s", "MESH_CLAUDE_CODE_FACTS": "/s/f", "MESH_CLAUDE_CODE_SETTINGS": "/s/x",
|
||||
"MESH_NODE": "anchor", "MESH_OPERATOR_ACCOUNT": "operator", "MESH_OPERATOR_HOME": "/home/operator"}
|
||||
p, ok := PathsFrom(envOf(base))
|
||||
if !ok || p.Home != "/home/operator" || p.Agent != "operator" || p.Confined() {
|
||||
t.Fatalf("a controller that names no agent account: the operator's, as before; got %+v", p)
|
||||
}
|
||||
with := map[string]string{"MESH_AGENT_ACCOUNT": "agent", "MESH_AGENT_HOME": "/home/agent"}
|
||||
for k, v := range base {
|
||||
with[k] = v
|
||||
}
|
||||
p, ok = PathsFrom(envOf(with))
|
||||
if !ok || p.Home != "/home/agent" || p.Agent != "agent" || p.OperatorHome != "/home/operator" || !p.Confined() {
|
||||
t.Fatalf("an agent account of its own: its home; got %+v", p)
|
||||
}
|
||||
if p.keyDir() != "/home/agent/.claude/mesh" || p.placed() != "/s/home-placed-agent.json" {
|
||||
t.Fatalf("the key and the record follow the agent's account: %s %s", p.keyDir(), p.placed())
|
||||
}
|
||||
with["MESH_AGENT_ACCOUNT"], with["MESH_AGENT_HOME"] = "operator", "/home/operator"
|
||||
if p, _ = PathsFrom(envOf(with)); p.Confined() || p.keyDir() != "/s" || p.placed() != "/s/home-placed.json" {
|
||||
t.Fatalf("the agent account named as the operator's is the operator's: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the licence follows the account ----------------------------------------------------------------------
|
||||
|
||||
func TestABindingAppliedIsFetchedAgainWhenTheAgentsHomeHoldsNoToken(t *testing.T) {
|
||||
p, _ := node(t, "anchor")
|
||||
writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`)
|
||||
asked := 0
|
||||
ask := func(string, any) (json.RawMessage, error) { asked++; return json.RawMessage(`{}`), nil }
|
||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(map[string]string{})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asked != 1 {
|
||||
t.Fatalf("a home with no token is asked for one, though the binding is the one applied; asked %d", asked)
|
||||
}
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"a","expiresAt":1}}`)
|
||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(map[string]string{})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asked != 1 {
|
||||
t.Fatal("a home that holds the token applied is not asked again")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- what goes from the operator's home -------------------------------------------------------------------
|
||||
|
||||
func confined(t *testing.T) Paths {
|
||||
t.Helper()
|
||||
p, _ := node(t, "anchor")
|
||||
p.Agent, p.Operator = "agent", "operator"
|
||||
p.OperatorHome = filepath.Join(filepath.Dir(p.Home), "operator")
|
||||
if err := os.MkdirAll(filepath.Join(p.OperatorHome, ".claude", "rules"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestTheTokenTheModulePlacedInTheOperatorsHomeGoes(t *testing.T) {
|
||||
p := confined(t)
|
||||
file := filepath.Join(p.OperatorHome, ".claude", ".credentials.json")
|
||||
writeFile(t, file, `{"claudeAiOauth":{"accessToken":"a","expiresAt":1}}`)
|
||||
writeFile(t, filepath.Join(p.State, "api-key"), "k\n")
|
||||
if said := strings.Join(RetireOperatorHome(p), "\n"); !strings.Contains(said, "until agent's home holds") {
|
||||
t.Fatalf("while the agent's home holds no licence the operator's token stays: %s", said)
|
||||
}
|
||||
if _, err := os.Stat(file); err != nil {
|
||||
t.Fatal("the operator's token went before the agent's home held one")
|
||||
}
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"b","expiresAt":1}}`)
|
||||
said := strings.Join(RetireOperatorHome(p), "\n")
|
||||
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
||||
t.Fatalf("the access-token-only file is the module's and goes: %s", said)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(p.State, "api-key")); !os.IsNotExist(err) {
|
||||
t.Fatal("the key kept where the agent's account cannot read it goes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestALoginThePersonMadeInTheOperatorsHomeStays(t *testing.T) {
|
||||
p := confined(t)
|
||||
file := filepath.Join(p.OperatorHome, ".claude", ".credentials.json")
|
||||
writeFile(t, file, `{"claudeAiOauth":{"accessToken":"a","refreshToken":"r","expiresAt":1}}`)
|
||||
said := strings.Join(RetireOperatorHome(p), "\n")
|
||||
if _, err := os.Stat(file); err != nil || !strings.Contains(said, "login the person made") {
|
||||
t.Fatalf("a refresh token is a person's login: left and said; said %q", said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatWasPlacedInTheOperatorsHomeGoesUnlessChanged(t *testing.T) {
|
||||
p := confined(t)
|
||||
dir := filepath.Join(p.OperatorHome, ".claude")
|
||||
writeFile(t, filepath.Join(dir, "rules", "a.md"), "placed")
|
||||
writeFile(t, filepath.Join(dir, "rules", "b.md"), "changed by hand")
|
||||
raw, _ := json.Marshal(Placed{"rules/a.md": digest("placed"), "rules/b.md": digest("placed")})
|
||||
writeFile(t, filepath.Join(p.State, "home-placed.json"), string(raw))
|
||||
RetireOperatorHome(p)
|
||||
if _, err := os.Stat(filepath.Join(dir, "rules", "a.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("an item placed and unchanged goes, as unregistering takes it")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "rules", "b.md")); err != nil {
|
||||
t.Fatal("an item the person changed is theirs now and stays")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(p.State, "home-placed.json")); !os.IsNotExist(err) {
|
||||
t.Fatal("the operator's home's record goes once it holds nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingGoesWhereTheAgentRunsAsTheOperator(t *testing.T) {
|
||||
p, _ := node(t, "anchor")
|
||||
p.OperatorHome = p.Home
|
||||
file := p.credentials()
|
||||
writeFile(t, file, `{"claudeAiOauth":{"accessToken":"a","expiresAt":1}}`)
|
||||
if said := RetireOperatorHome(p); len(said) != 0 {
|
||||
t.Fatalf("said %v", said)
|
||||
}
|
||||
if _, err := os.Stat(file); err != nil {
|
||||
t.Fatal("the token stays where the agent reads it")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the guard --------------------------------------------------------------------------------------------
|
||||
|
||||
func TestASessionNotRunningAsTheAgentsAccountDoesNothing(t *testing.T) {
|
||||
g := Guard{Data: GuardData{Node: "anchor", Agent: "agent"}}
|
||||
echo := HookInput{ToolName: "Bash", ToolInput: map[string]any{"command": "echo hello"}}
|
||||
v := g.JudgeAs("operator", echo)
|
||||
if !v.Refuse || v.Rule != "account" || v.Overridable || !strings.Contains(v.Message, "claude-agent") {
|
||||
t.Fatalf("a session as the operator is refused whatever it runs, with no override: %+v", v)
|
||||
}
|
||||
if v := g.JudgeAs("operator", HookInput{ToolName: "Write", ToolInput: map[string]any{"file_path": "/tmp/x"}}); !v.Refuse {
|
||||
t.Fatal("a file edit too")
|
||||
}
|
||||
if v := g.JudgeAs("agent", echo); v.Refuse {
|
||||
t.Fatalf("the agent's own account runs as before: %+v", v)
|
||||
}
|
||||
if v := (Guard{Data: GuardData{Node: "laptop"}}).JudgeAs("operator", echo); v.Refuse {
|
||||
t.Fatal("where agents run as the operator, the operator's session runs as before")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhereTheAgentHasItsOwnAccountTheRecordIsTheJournal(t *testing.T) {
|
||||
p, _ := node(t, "anchor")
|
||||
if d := GuardDataOf(p, nil); d.Agent != "" || strings.HasPrefix(d.Log, JournalRecord) {
|
||||
t.Fatalf("as the operator: the module's own file; got %+v", d)
|
||||
}
|
||||
p.Agent, p.Operator = "agent", "operator"
|
||||
d := GuardDataOf(p, nil)
|
||||
if d.Agent != "agent" || d.Log != JournalRecord+GuardJournalID {
|
||||
t.Fatalf("as an account of its own: the journal, and the account named; got %+v", d)
|
||||
}
|
||||
|
||||
socket := filepath.Join(t.TempDir(), "journal")
|
||||
conn, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: socket, Net: "unixgram"})
|
||||
if err != nil {
|
||||
t.Skipf("no unix datagram socket here: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
was := journalSocket
|
||||
journalSocket = socket
|
||||
t.Cleanup(func() { journalSocket = was })
|
||||
if err := RecordGuard(d.Log, GuardEntry{Decision: "refused", Rule: "account", Tool: "Bash", Command: "a\nb"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := make([]byte, 4096)
|
||||
n, _, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(string(buf[:n]), "\n"), "\n")
|
||||
if len(lines) != 3 || lines[0] != "SYSLOG_IDENTIFIER="+GuardJournalID || !strings.HasPrefix(lines[2], "MESSAGE={") {
|
||||
t.Fatalf("one line per field, the entry as one line of JSON: %q", lines)
|
||||
}
|
||||
|
||||
readWas := readJournal
|
||||
t.Cleanup(func() { readJournal = readWas })
|
||||
readJournal = func(id string, last int) ([]byte, error) {
|
||||
if id != GuardJournalID {
|
||||
t.Fatalf("read %s", id)
|
||||
}
|
||||
msg, _ := json.Marshal(strings.TrimPrefix(lines[2], "MESSAGE="))
|
||||
forged, _ := json.Marshal(`{"decision":"overridden","rule":"ssh","tool":"Bash","command":"forged"}`)
|
||||
return []byte(`{"MESSAGE":` + string(msg) + `,"_EXE":"` + HelperPath() + `"}` + "\n" +
|
||||
`{"MESSAGE":` + string(forged) + `,"_EXE":"/usr/bin/logger"}` + "\n"), nil
|
||||
}
|
||||
if got := ReadGuardLog(d.Log, 10); len(got) != 1 || got[0].Rule != "account" || got[0].Command != "a\nb" {
|
||||
t.Fatalf("the record reads back from the journal: %+v", got)
|
||||
}
|
||||
if err := RecordGuard(JournalRecord+"x", GuardEntry{}); err != nil && !strings.Contains(err.Error(), "journal") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
journalSocket = filepath.Join(t.TempDir(), "absent")
|
||||
if err := RecordGuard(d.Log, GuardEntry{}); err == nil {
|
||||
t.Fatal("a journal that cannot be reached is an error, so an override is not honoured unrecorded")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- an API key is taken only from the agent's own home ---------------------------------------------------
|
||||
|
||||
func TestAnAPIKeyFileOutsideTheAgentsHomeIsRefusedAndKept(t *testing.T) {
|
||||
p, _ := node(t, "anchor")
|
||||
asked := 0
|
||||
ask := func(string, any) (json.RawMessage, error) { asked++; return json.RawMessage(`{}`), nil }
|
||||
outside := filepath.Join(filepath.Dir(p.Home), "runtime-credential")
|
||||
writeFile(t, outside, "sk-ant-api03-secret-0123456789abcdefghij\n")
|
||||
inside := filepath.Join(p.Home, "not-a-key")
|
||||
writeFile(t, inside, "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n")
|
||||
linked := filepath.Join(p.Home, "linked")
|
||||
if err := os.Symlink(outside, linked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, file := range []string{outside, p.Home + "/../runtime-credential", inside, linked, "relative", p.Home} {
|
||||
_, err := AddAPIKey(p, "api", file, false, ask)
|
||||
if err == nil || strings.Contains(err.Error(), "secret") {
|
||||
t.Fatalf("%s: refused, without repeating what it holds; got %v", file, err)
|
||||
}
|
||||
}
|
||||
for _, file := range []string{outside, inside} {
|
||||
if _, err := os.Stat(file); err != nil {
|
||||
t.Fatalf("%s was removed though it was refused", file)
|
||||
}
|
||||
}
|
||||
if asked != 0 {
|
||||
t.Fatal("nothing refused reaches the licence manager")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGuardIsAskedAboutEveryToolAndRefusesEachForAnotherAccount(t *testing.T) {
|
||||
if GuardMatcher != "*" {
|
||||
t.Fatalf("the guard is asked about every tool, got %q", GuardMatcher)
|
||||
}
|
||||
g := Guard{Data: GuardData{Node: "anchor", Agent: "agent"}}
|
||||
for _, tool := range []string{"Read", "Grep", "WebFetch", "mcp__mesh__mesh_call", "Task"} {
|
||||
if v := g.JudgeAs("operator", HookInput{ToolName: tool}); !v.Refuse {
|
||||
t.Fatalf("%s as another account is refused", tool)
|
||||
}
|
||||
if v := g.JudgeAs("agent", HookInput{ToolName: tool}); v.Refuse {
|
||||
t.Fatalf("%s as the agent's account passes", tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMoveWritesTheAccountFileBesideTheToken(t *testing.T) {
|
||||
p, w := node(t, "anchor")
|
||||
writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`)
|
||||
ask := func(address string, args any) (json.RawMessage, error) {
|
||||
key := args.(map[string]any)["public_key"].(string)
|
||||
g, _ := json.Marshal(Grant{AccessToken: "at", ExpiresAt: now + 3_600_000})
|
||||
box, _ := Seal(string(g), key)
|
||||
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 4, Sealed: &box,
|
||||
Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}})
|
||||
}
|
||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if creds(t, p)["accessToken"] != "at" {
|
||||
t.Fatal("the token is in the new home")
|
||||
}
|
||||
if id := ReadIdentity(p.account()); id == nil || id.AccountUUID != "u-1" {
|
||||
t.Fatalf("the account file names the account whose token the new home holds, got %+v", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerPastTheBoundIsRefused(t *testing.T) {
|
||||
long := strings.Repeat("x", 100) + "\n"
|
||||
if _, err := readLine(bufio.NewReaderSize(strings.NewReader(long), 16), 50); err == nil {
|
||||
t.Fatal("a line past the bound is an error")
|
||||
}
|
||||
if got, err := readLine(bufio.NewReaderSize(strings.NewReader(long), 16), 200); err != nil || string(got) != long {
|
||||
t.Fatalf("a line within it is read whole across the buffer: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user