repos
/ orchard main

orchard

mirror

Every 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

7.9 KB · 234 lines · Go Raw History
  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}