okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffauffix(cli): resume child sessions by full ID4434577

main
8.8 KiB224 linesraw
1// A controlled daemon exposes unnecessary list requests and preserves exact
2// error routing; real-daemon E2E covers the CLI commands and shortened IDs.
3package app
4
5import (
6	"context"
7	"encoding/json"
8	"errors"
9	"fmt"
10	"net"
11	"net/http"
12	"net/http/httptest"
13	"slices"
14	"strings"
15	"testing"
16
17	"albedo/cli/internal/daemon"
18	"albedo/cli/internal/testwire"
19)
20
21func TestSessionResolutionReadsFullIDsAndListsOnlyPrefixes(t *testing.T) {
22	const id = "019c1234-5678-7000-8000-000000000001"
23	const sibling = "019c1234-5678-7000-8000-000000000002"
24	for _, scenario := range []struct {
25		name, requested, wantID, wantError string
26		status                             int
27		ids                                []string
28		paths                              []string
29	}{
30		{
31			name: "full", requested: id, wantID: id,
32			paths: []string{"/sessions/" + id + "?view=configuration"},
33		},
34		{
35			name: "missing full", requested: id, status: 404,
36			wantError: fmt.Sprintf("no session matches %q; run albedo sessions to see the available sessions", id),
37			paths:     []string{"/sessions/" + id + "?view=configuration"},
38		},
39		{
40			name: "refused full", requested: id,
41			status: 403, wantError: "refused",
42			paths: []string{"/sessions/" + id + "?view=configuration"},
43		},
44		{
45			name: "prefix", requested: id[:8],
46			ids: []string{id}, wantID: id,
47			paths: []string{"/sessions?scope=roots&limit=200"},
48		},
49		{
50			name: "exact match wins", requested: id[:8],
51			ids: []string{id, id[:8]}, wantID: id[:8],
52			paths: []string{"/sessions?scope=roots&limit=200"},
53		},
54		{
55			name: "ambiguous prefix", requested: id[:8],
56			ids: []string{id, sibling}, wantError: "more than one session ID",
57			paths: []string{"/sessions?scope=roots&limit=200"},
58		},
59		{
60			name: "missing prefix", requested: "missing",
61			wantError: "no session matches",
62			paths:     []string{"/sessions?scope=roots&limit=200"},
63		},
64		{
65			name: "uppercase stays exact", requested: strings.ToUpper(id),
66			wantError: "no session matches",
67			paths:     []string{"/sessions?scope=roots&limit=200"},
68		},
69	} {
70		t.Run(scenario.name, func(t *testing.T) {
71			var paths []string
72			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
73				paths = append(paths, r.URL.RequestURI())
74				w.Header().Set("Content-Type", "application/json")
75				if scenario.status != 0 {
76					w.WriteHeader(scenario.status)
77					_ = json.NewEncoder(w).Encode(map[string]any{"status": scenario.status, "code": "resource_not_found", "detail": "refused"})
78					return
79				}
80				if r.URL.Path == "/sessions" {
81					items := []any{}
82					for _, sessionID := range scenario.ids {
83						items = append(items, testwire.Session(sessionID, testwire.GenerationA, 0))
84					}
85					_ = json.NewEncoder(w).Encode(map[string]any{"items": items, "next": nil})
86					return
87				}
88				session := testwire.Session(id, testwire.GenerationA, 0)
89				resource := session["configuration_resource"].(map[string]any)
90				w.Header().Set("ETag", resource["etag"].(string))
91				_ = json.NewEncoder(w).Encode(resource["value"])
92			}))
93			defer server.Close()
94			connection := daemon.NewConnection(daemon.ConnectionSnapshot{Port: server.Listener.Addr().(*net.TCPAddr).Port}, nil)
95			defer connection.HTTPClient().CloseIdleConnections()
96			resolved, err := resolveSession(t.Context(), connection, scenario.requested)
97			if resolved != scenario.wantID || (scenario.wantError == "" && err != nil) || (scenario.wantError != "" && (err == nil || !strings.Contains(err.Error(), scenario.wantError))) {
98				t.Fatalf("resolution: id=%q error=%v", resolved, err)
99			}
100			if scenario.status == 403 {
101				if problem, ok := errors.AsType[*daemon.APIError](err); !ok || problem.StatusCode != 403 {
102					t.Fatalf("lost original refusal: %v", err)
103				}
104			}
105			if !slices.Equal(paths, scenario.paths) {
106				t.Fatalf("requests=%v, want %v", paths, scenario.paths)
107			}
108		})
109	}
110}
111
112func TestPrepareOpenReusesBootstrapReads(t *testing.T) {
113	const root = "019c1234-5678-7000-8000-000000000001"
114	const child = "019c1234-5678-7000-8000-000000000002"
115	for _, scenario := range []struct {
116		name, requested, selected, wantError string
117		roots                                []string
118		status                               int
119		cancel                               bool
120	}{
121		{name: "root full", requested: root, selected: root, roots: []string{root}},
122		{name: "root prefix", requested: root[:8], selected: root, roots: []string{root}},
123		{name: "child full", requested: child, selected: child, roots: []string{root}},
124		{name: "missing full", requested: child, roots: []string{root}, status: http.StatusNotFound, wantError: "no session matches"},
125		{name: "refused full", requested: child, roots: []string{root}, status: http.StatusForbidden, wantError: "refused"},
126		{name: "cancelled capture", requested: child, roots: []string{root}, cancel: true},
127		{name: "ambiguous prefix", requested: root[:8], roots: []string{root, child}, wantError: "more than one session ID"},
128		{name: "missing prefix", requested: "missing", roots: []string{root}, wantError: "no session matches"},
129		{name: "uppercase full", requested: strings.ToUpper(root), roots: []string{root}, wantError: "no session matches"},
130	} {
131		t.Run(scenario.name, func(t *testing.T) {
132			ctx, cancel := context.WithCancel(t.Context())
133			defer cancel()
134			var paths []string
135			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136				paths = append(paths, r.URL.RequestURI())
137				w.Header().Set("Content-Type", "application/json")
138				switch r.URL.Path {
139				case "/sessions":
140					if r.Method != http.MethodGet {
141						t.Error("failed lookup attempted to create a replacement session")
142						w.WriteHeader(http.StatusBadRequest)
143						return
144					}
145					items := []any{}
146					for _, id := range scenario.roots {
147						items = append(items, testwire.Session(id, testwire.GenerationA, 0))
148					}
149					_ = json.NewEncoder(w).Encode(map[string]any{"items": items, "next": nil})
150				case "/settings":
151					settings := testwire.Settings()
152					settings["providers"].(map[string]any)["default_profile"] = "fixture"
153					_ = json.NewEncoder(w).Encode(settings)
154				case "/sessions/" + scenario.requested, "/sessions/" + scenario.selected:
155					if scenario.cancel {
156						cancel()
157						<-r.Context().Done()
158						return
159					}
160					if scenario.status != 0 {
161						w.WriteHeader(scenario.status)
162						_ = json.NewEncoder(w).Encode(map[string]any{"status": scenario.status, "code": "resource_not_found", "detail": "refused"})
163						return
164					}
165					captured := testwire.Session(scenario.selected, testwire.GenerationA, 0)
166					captured["name"] = "Captured session"
167					_ = json.NewEncoder(w).Encode(captured)
168				default:
169					t.Errorf("unexpected request %s", r.URL.RequestURI())
170					w.WriteHeader(http.StatusBadRequest)
171				}
172			}))
173			defer server.Close()
174			connection := daemon.NewConnection(daemon.ConnectionSnapshot{Port: server.Listener.Addr().(*net.TCPAddr).Port}, nil)
175			defer connection.HTTPClient().CloseIdleConnections()
176			service := Service{Connect: func(context.Context) (*daemon.Connection, error) { return connection, nil }}
177			opened, err := service.PrepareOpen(ctx, OpenOptions{SessionID: scenario.requested, Workspace: "/bootstrap-work", Fresh: true})
178			if scenario.cancel {
179				if !errors.Is(err, context.Canceled) {
180					t.Fatalf("lost cancellation: %v", err)
181				}
182			} else if scenario.wantError != "" {
183				if err == nil || !strings.Contains(err.Error(), scenario.wantError) {
184					t.Fatalf("lookup error=%v, want %q", err, scenario.wantError)
185				}
186				if scenario.status == http.StatusForbidden {
187					if problem, ok := errors.AsType[*daemon.APIError](err); !ok || problem.StatusCode != http.StatusForbidden {
188						t.Fatalf("lost original refusal: %v", err)
189					}
190				}
191			} else {
192				if err != nil || opened.Selected == nil || opened.Selected.ID != scenario.selected || opened.Selected.Title != "Captured session" {
193					t.Fatalf("selected=%+v error=%v", opened.Selected, err)
194				}
195				count := 0
196				for _, session := range opened.Sessions {
197					if session.ID == scenario.selected {
198						count++
199						if session.Title != "Captured session" {
200							t.Fatal("bootstrap retained stale root summary")
201						}
202					}
203				}
204				if count != 1 || opened.LoginRequired || opened.Workspace != "/bootstrap-work" || opened.Settings.Profiles.Active != "fixture" {
205					t.Fatalf("bootstrap: selected count=%d, prepared=%+v", count, opened)
206				}
207				if scenario.selected == child && opened.Sessions[0].ID != child {
208					t.Fatal("child was not prepended to bootstrap sessions")
209				}
210			}
211			wantPaths := []string{"/sessions?scope=roots&limit=200"}
212			if scenario.status != 0 || scenario.cancel || scenario.selected != "" {
213				id := scenario.selected
214				if id == "" {
215					id = scenario.requested
216				}
217				wantPaths = append(wantPaths, "/settings", "/sessions/"+id+"?tail=0")
218			}
219			if !slices.Equal(paths, wantPaths) {
220				t.Fatalf("requests=%v, want %v", paths, wantPaths)
221			}
222		})
223	}
224}