orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 "time"
12)
13
14func testDeps(base string) *Deps {
15 return &Deps{HTTP: &http.Client{Timeout: 5 * time.Second}, Now: time.Now, Guard: NewGuard(time.Minute)}
16}
17
18// Without a session these tools have no way to prove who is asking, and the
19// failure has to be local rather than an unauthenticated request going out.
20func TestEstateToolsRefuseWithoutASession(t *testing.T) {
21 reached := false
22 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23 reached = true
24 }))
25 defer srv.Close()
26
27 var out any
28 err := estateGet(context.Background(), testDeps(srv.URL), srv.URL, &out)
29 if err == nil {
30 t.Fatal("an unsigned request was allowed")
31 }
32 if !strings.Contains(err.Error(), "signed in") {
33 t.Errorf("err = %q, want it to name the reason", err)
34 }
35 if reached {
36 t.Error("a request went out with no session on it")
37 }
38}
39
40func TestEstateToolsForwardTheSession(t *testing.T) {
41 var got string
42 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43 if c, err := r.Cookie(SessionCookie); err == nil {
44 got = c.Value
45 }
46 _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
47 }))
48 defer srv.Close()
49
50 d := testDeps(srv.URL).WithSession("a-live-session")
51 var out map[string]any
52 if err := estateGet(context.Background(), d, srv.URL, &out); err != nil {
53 t.Fatal(err)
54 }
55 if got != "a-live-session" {
56 t.Errorf("cookie forwarded as %q", got)
57 }
58 if out["ok"] != true {
59 t.Errorf("body = %v", out)
60 }
61}
62
63// WithSession must copy, or one person's session lands on the shared Deps and
64// the next turn borrows it.
65func TestWithSessionDoesNotMutateTheShared(t *testing.T) {
66 base := testDeps("")
67 a := base.WithSession("one")
68 b := base.WithSession("two")
69 if base.Session != "" {
70 t.Errorf("the shared Deps was written to: %q", base.Session)
71 }
72 if a.Session != "one" || b.Session != "two" {
73 t.Errorf("copies crossed: %q and %q", a.Session, b.Session)
74 }
75}
76
77// A redirect to the login page is a sign in prompt, and decoding it as JSON
78// would report a parse error instead of the real problem.
79func TestEstateToolsReadARedirectAsSignedOut(t *testing.T) {
80 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
81 http.Redirect(w, r, "https://auth.bythewood.me/login", http.StatusSeeOther)
82 }))
83 defer srv.Close()
84
85 d := testDeps(srv.URL).WithSession("stale")
86 // The client must not follow it, or the test proves nothing.
87 d.HTTP = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
88 return http.ErrUseLastResponse
89 }}
90 var out any
91 err := estateGet(context.Background(), d, srv.URL, &out)
92 if err == nil || !strings.Contains(err.Error(), "sign in") {
93 t.Errorf("err = %v, want a sign in", err)
94 }
95}
96
97func TestEstateToolsReportARefusedSession(t *testing.T) {
98 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
99 w.WriteHeader(http.StatusUnauthorized)
100 }))
101 defer srv.Close()
102
103 var out any
104 err := estateGet(context.Background(), testDeps(srv.URL).WithSession("revoked"), srv.URL, &out)
105 if err == nil || !strings.Contains(err.Error(), "signed out") {
106 t.Errorf("err = %v, want it to say the session was refused", err)
107 }
108}
109
110// Every orchard tool has to be offered, or one exists in the dispatch table and
111// not in the schemas the model sees.
112func TestOrchardToolsAreRegistered(t *testing.T) {
113 r := Default()
114 for _, name := range []string{"orchard_logs", "orchard_status", "orchard_analytics", "orchard_repos", "orchard_dash"} {
115 if _, ok := r.Get(name); !ok {
116 t.Errorf("%s is not registered", name)
117 }
118 }
119}
120
121// A model totalling a statement writes every line into one expression and runs
122// out of tokens partway, so the call arrives cut in half. The cap turns that
123// into advice it can act on instead of a sum of whatever survived.
124func TestCalcRefusesAnExpressionTooLongToBeWhole(t *testing.T) {
125 long := strings.TrimSuffix(strings.Repeat("14.45+", 200), "+")
126 _, err := Calc.Run(context.Background(), testDeps(""), map[string]any{"expression": long})
127 if err == nil {
128 t.Fatal("a runaway expression was evaluated")
129 }
130 if !strings.Contains(err.Error(), "groups") {
131 t.Errorf("err = %q, want it to say what to do instead", err)
132 }
133 // An ordinary sum still works.
134 got, err := Calc.Run(context.Background(), testDeps(""), map[string]any{"expression": "14.45+10.70+15.52"})
135 if err != nil {
136 t.Fatalf("an ordinary sum was refused: %v", err)
137 }
138 if !strings.Contains(fmt.Sprint(got), "40.67") {
139 t.Errorf("got %v", got)
140 }
141}
142
143// news reads every feed on the list, so a second call in one turn re-reads all
144// of them for a rundown the turn already has. The prompt asks for one and this
145// is what makes it one, so the harness has to be able to take a tool off the
146// table mid turn.
147func TestWithoutTakesAToolOffTheTable(t *testing.T) {
148 all := []map[string]any{
149 {"type": "function", "function": map[string]any{"name": News.Name}},
150 {"type": "function", "function": map[string]any{"name": WebSearch.Name}},
151 }
152 trimmed := Without(all, News.Name)
153 if len(trimmed) != 1 {
154 t.Fatalf("%d schemas left, want 1", len(trimmed))
155 }
156 for _, sc := range trimmed {
157 fn, _ := sc["function"].(map[string]any)
158 if fn["name"] == News.Name {
159 t.Error("news survived being removed")
160 }
161 }
162 if len(all) != 2 {
163 t.Error("the original list was modified")
164 }
165}
166
167func TestOrchardCodeReadsAFileAndListsADirectory(t *testing.T) {
168 var asked []string
169 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
170 asked = append(asked, r.URL.Path)
171 switch r.URL.Path {
172 case "/api/repos/orchard/file/HEAD/sites/chat.bythewood.me/tools/web.go":
173 _ = json.NewEncoder(w).Encode(map[string]any{
174 "repo": "orchard", "path": "tools/web.go", "text": "package tools", "lines": 1})
175 case "/api/repos/orchard/tree/HEAD/sites":
176 _ = json.NewEncoder(w).Encode(map[string]any{
177 "repo": "orchard", "path": "sites", "count": 1,
178 "entries": []map[string]any{{"name": "chat.bythewood.me", "type": "tree"}}})
179 default:
180 http.Error(w, `{"error":"no such path"}`, http.StatusNotFound)
181 }
182 }))
183 defer srv.Close()
184
185 old := reposBase
186 reposBase = srv.URL
187 defer func() { reposBase = old }()
188 d := testDeps(srv.URL).WithSession("a-live-session")
189
190 got, err := OrchardCode.Run(context.Background(), d,
191 map[string]any{"repo": "orchard", "path": "sites/chat.bythewood.me/tools/web.go"})
192 if err != nil {
193 t.Fatalf("reading a file failed: %v", err)
194 }
195 if m, ok := got.(map[string]any); !ok || m["text"] != "package tools" {
196 t.Fatalf("the file came back as %#v", got)
197 }
198
199 // A directory is not a file, and the 404 on the file read has to fall
200 // through to the listing rather than being reported as a missing path.
201 got, err = OrchardCode.Run(context.Background(), d,
202 map[string]any{"repo": "orchard", "path": "sites"})
203 if err != nil {
204 t.Fatalf("listing a directory failed: %v", err)
205 }
206 if m, ok := got.(map[string]any); !ok || m["count"] != float64(1) {
207 t.Fatalf("the listing came back as %#v", got)
208 }
209
210 // A path that is neither says so, and says what to do about it, rather
211 // than handing back an empty listing that reads as an empty directory.
212 _, err = OrchardCode.Run(context.Background(), d,
213 map[string]any{"repo": "orchard", "path": "sites/nope/nothing.go"})
214 if err == nil {
215 t.Fatal("a path that does not exist came back as a result")
216 }
217 if !strings.Contains(err.Error(), "list the level above") {
218 t.Errorf("err = %q, want it to say what to do next", err)
219 }
220 _ = asked
221}
222
223// A path is one wildcard on the other side, so escaping it whole would turn
224// every separator into %2F and nothing would ever resolve.
225func TestOrchardCodeKeepsPathSeparators(t *testing.T) {
226 if got, want := escapePath("sites/chat.bythewood.me/tools/web.go"),
227 "sites/chat.bythewood.me/tools/web.go"; got != want {
228 t.Errorf("escapePath = %q, want %q", got, want)
229 }
230 if got := escapePath("a dir/a file.go"); got != "a%20dir/a%20file.go" {
231 t.Errorf("escapePath left a space unescaped: %q", got)
232 }
233}