Files
mesh-catalog/modules/gitea/cmd/agent-token/token_test.go
T

377 lines
13 KiB
Go

package main
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"testing"
"golang.org/x/crypto/nacl/box"
"gitea/internal/forgehttp"
)
const adminPassword = "admin-password-never-said"
// fakeForge is gitea's API as far as this bundle uses it, as the administrator's login reaches it.
type fakeForge struct {
mu sync.Mutex
users map[string]*forgeUser
tokens map[string][]forgeToken
next int64
calls []string
made map[string]map[string]any
minted map[string][]string // account -> scopes of its last mint
failMint bool
}
func newFakeForge() *fakeForge {
return &fakeForge{users: map[string]*forgeUser{}, tokens: map[string][]forgeToken{}, made: map[string]map[string]any{},
minted: map[string][]string{}, next: 1}
}
var tokenPath = regexp.MustCompile(`^/api/v1/users/([^/]+)/tokens(?:/(\d+))?$`)
func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
user, pass, ok := r.BasicAuth()
if !ok || user != "mesh-admin" || pass != adminPassword {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
f.calls = append(f.calls, r.Method+" "+r.URL.Path)
body, _ := io.ReadAll(r.Body)
var in map[string]any
_ = json.Unmarshal(body, &in)
reply := func(status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if v != nil {
_ = json.NewEncoder(w).Encode(v)
}
}
switch {
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/users/") && strings.Count(r.URL.Path, "/") == 4:
u, ok := f.users[strings.TrimPrefix(r.URL.Path, "/api/v1/users/")]
if !ok {
reply(404, map[string]any{"message": "user does not exist"})
return
}
reply(200, map[string]any{"is_admin": u.IsAdmin, "active": u.Active, "prohibit_login": u.ProhibitLogin})
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users":
name, _ := in["username"].(string)
f.users[name] = &forgeUser{Active: true}
f.made[name] = in
reply(201, map[string]any{"login": name})
case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/api/v1/admin/users/"):
u := f.users[strings.TrimPrefix(r.URL.Path, "/api/v1/admin/users/")]
u.IsAdmin, _ = in["admin"].(bool)
u.Active, _ = in["active"].(bool)
u.ProhibitLogin, _ = in["prohibit_login"].(bool)
reply(200, map[string]any{})
case tokenPath.MatchString(r.URL.Path):
m := tokenPath.FindStringSubmatch(r.URL.Path)
account := m[1]
switch r.Method {
case http.MethodGet:
reply(200, f.tokens[account])
case http.MethodDelete:
kept := f.tokens[account][:0]
for _, t := range f.tokens[account] {
if fmt.Sprint(t.ID) != m[2] {
kept = append(kept, t)
}
}
f.tokens[account] = kept
reply(204, nil)
case http.MethodPost:
if f.failMint {
reply(500, map[string]any{"message": "the forge is unwell"})
return
}
name, _ := in["name"].(string)
for _, t := range f.tokens[account] {
if t.Name == name {
reply(400, map[string]any{"message": "access token name has been used already"})
return
}
}
var scopes []string
for _, s := range in["scopes"].([]any) {
scopes = append(scopes, s.(string))
}
f.minted[account] = scopes
b := make([]byte, 20)
_, _ = rand.Read(b)
t := forgeToken{ID: f.next, Name: name, SHA1: fmt.Sprintf("%x", b)}
f.next++
f.tokens[account] = append(f.tokens[account], forgeToken{ID: t.ID, Name: t.Name})
reply(201, t)
}
default:
reply(404, map[string]any{"message": "not here: " + r.URL.Path})
}
}
type harness struct {
forge *fakeForge
server *httptest.Server
minter *Minter
public *[32]byte
private *[32]byte
logged *[]string
}
func newHarness(t *testing.T) harness {
t.Helper()
f := newFakeForge()
srv := httptest.NewServer(f)
t.Cleanup(srv.Close)
dir := t.TempDir()
pw := filepath.Join(dir, "admin.secret")
origins := filepath.Join(dir, "origins")
if err := os.WriteFile(pw, []byte(adminPassword+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(origins, []byte("https://git.example.test\n"), 0o644); err != nil {
t.Fatal(err)
}
pub, priv, err := box.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
var logged []string
m := &Minter{Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Node: "anchor",
OriginsFile: origins, Log: func(format string, a ...any) { logged = append(logged, fmt.Sprintf(format, a...)) }}
return harness{forge: f, server: srv, minter: m, public: pub, private: priv, logged: &logged}
}
func (h harness) open(t *testing.T, sealed string) string {
t.Helper()
raw, err := base64.StdEncoding.DecodeString(sealed)
if err != nil {
t.Fatal(err)
}
out, ok := box.OpenAnonymous(nil, raw, h.public, h.private)
if !ok {
t.Fatal("a holder's value is not sealed to the call's key")
}
return string(out)
}
func (h harness) args(holders ...HolderAsk) map[string]any {
list := []any{}
for _, x := range holders {
list = append(list, map[string]any{"node": x.Node, "module": x.Module})
}
return map[string]any{"provision": Provision, "seal_to": base64.StdEncoding.EncodeToString(h.public[:]), "holders": list}
}
func TestOnlyTheVaultIsAnsweredAndNothingIsMintedForAnyoneElse(t *testing.T) {
h := newHarness(t)
for _, caller := range []string{"controller", "novox.node-tools", "provisioner.anchor", "anchor.claude-code",
"mesh-vault", "anchor.mesh-vault.x", "", "unproven"} {
if err := fromTheVault(caller); err == nil {
t.Errorf("%q was taken for the vault", caller)
}
}
if err := fromTheVault("novox.mesh-vault"); err != nil {
t.Errorf("the vault was refused: %v", err)
}
// Through the tool as the runtime serves it: a call no runtime named a caller for is the vault's to nobody.
tools := Tools(func() (*Minter, error) { return h.minter, nil })
if _, err := tools[0].Run(h.args(HolderAsk{"anchor", "claude-code"})); err == nil || !strings.Contains(err.Error(), "vault alone") {
t.Fatalf("an unproven caller was answered: %v", err)
}
if len(h.forge.calls) != 0 {
t.Fatalf("the forge was reached for a caller that is not the vault: %v", h.forge.calls)
}
}
func TestEachAgentGetsAnAccountOfItsOwnAndAReadTokenSealedForItAlone(t *testing.T) {
h := newHarness(t)
ask, err := readAsk(h.args(HolderAsk{"anchor", "claude-code"}, HolderAsk{"laptop", "claude-code"}, HolderAsk{"laptop", "lab"}))
if err != nil {
t.Fatal(err)
}
got, err := h.minter.Answer(ask)
if err != nil {
t.Fatal(err)
}
if len(got.Holders) != 2 {
t.Fatalf("answered %d holders, want the two agents: %+v", len(got.Holders), got)
}
if !strings.Contains(got.Skipped["lab on laptop"], Holder) {
t.Errorf("a holder that is not an agent's module was not said: %v", got.Skipped)
}
lines := map[string]string{}
for _, s := range got.Holders {
lines[s.Node] = h.open(t, s.Sealed)
}
anchorTok := tokenIn(t, lines["anchor"], "agent-anchor")
laptopTok := tokenIn(t, lines["laptop"], "agent-laptop")
if anchorTok == laptopTok {
t.Fatal("two agents were handed one token")
}
// The forge's own machine reaches it at its own address too; another machine only at its public origin.
if !strings.Contains(lines["anchor"], "https://agent-anchor:"+anchorTok+"@git.example.test\n") ||
!strings.Contains(lines["anchor"], strings.Replace(h.server.URL, "http://", "http://agent-anchor:"+anchorTok+"@", 1)) {
t.Errorf("anchor's credential lines: %q", strings.ReplaceAll(lines["anchor"], anchorTok, "<token>"))
}
if lines["laptop"] != "https://agent-laptop:"+laptopTok+"@git.example.test\n" {
t.Errorf("laptop's credential lines: %q", strings.ReplaceAll(lines["laptop"], laptopTok, "<token>"))
}
for _, account := range []string{"agent-anchor", "agent-laptop"} {
u := h.forge.users[account]
if u == nil || u.IsAdmin {
t.Errorf("%s: %+v, want a plain account", account, u)
}
if made := h.forge.made[account]; made["must_change_password"] != false || made["password"] == "" {
t.Errorf("%s was made with %v", account, made)
}
if !slices.Equal(h.forge.minted[account], []string{"read:repository"}) {
t.Errorf("%s's token carries %v, want read:repository alone", account, h.forge.minted[account])
}
}
for _, c := range h.forge.calls {
if strings.Contains(c, "/teams") || strings.Contains(c, "/orgs/") || strings.Contains(c, "collaborators") {
t.Errorf("an agent's account was given access (%s): it starts in no team, the operator grants (ADR 0365 §3)", c)
}
}
if _, made := h.forge.users["agent-laptop-lab"]; made || len(h.forge.users) != 2 {
t.Errorf("accounts made: %v", h.forge.users)
}
said := strings.Join(*h.logged, "\n")
if strings.Contains(said, anchorTok) || strings.Contains(said, laptopTok) {
t.Fatal("a token was said in the log")
}
}
func TestAskedAgainTheTokenIsMintedAnewAndTheOneFromBeforeDeleted(t *testing.T) {
h := newHarness(t)
ask, _ := readAsk(h.args(HolderAsk{"laptop", "claude-code"}))
first, err := h.minter.Answer(ask)
if err != nil {
t.Fatal(err)
}
h.forge.tokens["agent-laptop"] = append(h.forge.tokens["agent-laptop"], forgeToken{ID: 99, Name: "a-persons-own"})
again, err := h.minter.Answer(ask)
if err != nil {
t.Fatal(err)
}
if h.open(t, first.Holders[0].Sealed) == h.open(t, again.Holders[0].Sealed) {
t.Fatal("asked again, the same token was answered: it was not rotated")
}
var names []string
for _, tok := range h.forge.tokens["agent-laptop"] {
names = append(names, tok.Name)
}
slices.Sort(names)
if !slices.Equal(names, []string{"a-persons-own", TokenName}) {
t.Fatalf("the account holds %v: the token from before is not deleted, or another was", names)
}
}
func TestAnAccountMadeAnAdministratorByHandIsMadePlainAgain(t *testing.T) {
h := newHarness(t)
h.forge.users["agent-laptop"] = &forgeUser{IsAdmin: true, Active: true}
ask, _ := readAsk(h.args(HolderAsk{"laptop", "claude-code"}))
if _, err := h.minter.Answer(ask); err != nil {
t.Fatal(err)
}
if h.forge.users["agent-laptop"].IsAdmin {
t.Fatal("the agent's account is still an administrator")
}
}
func TestAFailureSaysNeitherTheAdminsPasswordNorATokenAndAnswersNothing(t *testing.T) {
h := newHarness(t)
h.forge.failMint = true
ask, _ := readAsk(h.args(HolderAsk{"anchor", "claude-code"}, HolderAsk{"laptop", "claude-code"}))
got, err := h.minter.Answer(ask)
if err == nil || len(got.Holders) != 0 {
t.Fatalf("a failed mint answered %+v, %v", got, err)
}
if strings.Contains(err.Error(), adminPassword) {
t.Fatal("the admin's password was said in an error")
}
// A wrong password is a refusal, said without it.
if err := os.WriteFile(h.minter.Forge.PasswordFile, []byte("wrong"), 0o600); err != nil {
t.Fatal(err)
}
h.forge.failMint = false
if _, err := h.minter.Answer(ask); err == nil || strings.Contains(err.Error(), "wrong") {
t.Fatalf("a refused login: %v", err)
}
}
func TestTheAskIsReadStrictly(t *testing.T) {
h := newHarness(t)
for name, args := range map[string]map[string]any{
"another provision": {"provision": "git", "seal_to": base64.StdEncoding.EncodeToString(h.public[:]),
"holders": []any{map[string]any{"node": "a", "module": "claude-code"}}},
"no key": {"provision": Provision, "holders": []any{map[string]any{"node": "a", "module": "claude-code"}}},
"short key": {"provision": Provision, "seal_to": "AAAA", "holders": []any{map[string]any{"node": "a", "module": "claude-code"}}},
"no holders": {"provision": Provision, "seal_to": base64.StdEncoding.EncodeToString(h.public[:])},
"a holder twice": {"provision": Provision, "seal_to": base64.StdEncoding.EncodeToString(h.public[:]),
"holders": []any{map[string]any{"node": "a", "module": "claude-code"}, map[string]any{"node": "a", "module": "claude-code"}}},
} {
if _, err := readAsk(args); err == nil {
t.Errorf("%s was taken", name)
}
}
}
func TestTheReadmeNamesWhatTheBundleServes(t *testing.T) {
var served []string
for _, tool := range Tools(MinterFromEnv) {
served = append(served, tool.Name)
}
if !slices.Equal(served, ToolNames) {
t.Fatalf("served %v, named %v", served, ToolNames)
}
readme, err := os.ReadFile("../../README.md")
if err != nil {
t.Fatal(err)
}
for _, n := range ToolNames {
if !strings.Contains(string(readme), "`"+n+"`") {
t.Errorf("the README does not name %s", n)
}
}
manifest, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(manifest), `"tool": "`+ToolName+`"`) || !strings.Contains(string(manifest), `"name": "`+Provision+`"`) {
t.Error("the manifest does not offer the provision with this tool as its issuer")
}
}
// tokenIn is the token of account's credential lines, all one.
func tokenIn(t *testing.T, lines, account string) string {
t.Helper()
re := regexp.MustCompile(regexp.QuoteMeta(account) + `:([0-9a-f]{40})@`)
all := re.FindAllStringSubmatch(lines, -1)
if len(all) == 0 {
t.Fatalf("no credential for %s in the lines", account)
}
for _, m := range all {
if m[1] != all[0][1] {
t.Fatalf("%s's lines carry two tokens", account)
}
}
return all[0][1]
}