package sessionread import ( "context" "database/sql" "errors" "fmt" "reflect" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" ) // envCtx builds a context carrying an explicit actor environment, the way the // gateway and channel bridge attach one per turn. func envCtx(env restrict.ActorEnv) context.Context { return loop.WithSessionEnv(context.Background(), loop.SessionEnv{ActorEnv: env, MemWritable: env == restrict.ActorOwner}) } // TestNonOwnerIsRefused locks the first half of the fail-closed whitelist: a group // or stranger conversation may not read another session at all, and the refusal // happens before any read — nothing about the target leaks (design/22 §8.1 约束 3). func TestNonOwnerIsRefused(t *testing.T) { t.Parallel() for _, env := range []restrict.ActorEnv{restrict.ActorGroupMember, restrict.ActorStranger} { t.Run(string(env), func(t *testing.T) { h := &fakeHistory{ sessions: []history.SessionMeta{{ID: history.MainSessionID}}, msgs: map[string][]history.Message{history.MainSessionID: mainMessages()}, } tool := newReader(h, testPersonas()) _, err := tool.Execute(envCtx(env), map[string]any{"sessionId": history.MainSessionID}) if !errors.Is(err, ErrNotOwner) { t.Fatalf("err = %v, want ErrNotOwner", err) } if h.listCalls != 0 { t.Fatalf("history was read %d times before the refusal, want 0", h.listCalls) } // The refusal must be distinguishable from "the session does not exist". if strings.Contains(err.Error(), ErrSessionNotFound.Error()) { t.Fatalf("err = %v, a refusal must not read like a missing session", err) } }) } } // TestReadableSessionID is the whitelist table: only the Web main session and Web // sub sessions are readable. In particular "web:" with no suffix, a bare prefix // look-alike and every IM session id are refused (design/22 §8.1 约束 3). func TestReadableSessionID(t *testing.T) { t.Parallel() cases := []struct { id string want bool }{ {history.MainSessionID, true}, {"web:5f1c8d2e-0000-1111-2222-333344445555", true}, {"web:", false}, {"web", true}, {"webx", false}, {"webs", false}, {"telegram:-100200300", false}, {"kook:3bef", false}, {"sess-group", false}, {"", false}, } for _, tc := range cases { if got := ReadableSessionID(tc.id); got != tc.want { t.Errorf("ReadableSessionID(%q) = %v, want %v", tc.id, got, tc.want) } } } // TestUnreadableSessionIsRefused locks the other half of the whitelist: an id // outside web/web: is refused even though it might exist, so an IM group or // stranger session can never be reached through this tool. func TestUnreadableSessionIsRefused(t *testing.T) { t.Parallel() h := &fakeHistory{ sessions: []history.SessionMeta{{ID: "telegram:-100200300", Title: "群聊"}}, msgs: map[string][]history.Message{"telegram:-100200300": {{Role: history.RoleUser, Content: "秘密", CreatedAt: at(21, 4)}}}, } tool := newReader(h, testPersonas()) _, err := tool.Execute(ownerCtx(), map[string]any{"sessionId": "telegram:-100200300"}) if !errors.Is(err, ErrSessionNotAllowed) { t.Fatalf("err = %v, want ErrSessionNotAllowed", err) } if h.listCalls != 0 { t.Fatalf("history was read %d times before the refusal, want 0", h.listCalls) } } // TestMissingSessionIsAnErrorNotEmptyMaterial locks design/22 §8.1 约束 3: a session // that does not exist is a hard, clearly labelled error, never empty material — // otherwise "read nothing" could be taken for "they said nothing". func TestMissingSessionIsAnErrorNotEmptyMaterial(t *testing.T) { t.Parallel() h := &fakeHistory{sessions: []history.SessionMeta{{ID: history.MainSessionID}}} tool := newReader(h, testPersonas()) out, err := tool.Execute(ownerCtx(), map[string]any{"sessionId": "web:missing"}) if !errors.Is(err, ErrSessionNotFound) { t.Fatalf("err = %v, want ErrSessionNotFound", err) } if out != nil { t.Fatalf("out = %#v, want nil alongside the error (no empty material)", out) } for _, other := range []error{ErrSessionNotAllowed, ErrNotOwner} { if errors.Is(err, other) { t.Fatalf("err = %v, must stay distinguishable from %v", err, other) } } if !strings.Contains(err.Error(), "session") { t.Fatalf("err = %v, want a human-readable missing-session message", err) } } // TestMissingArgumentIsRejected asserts the tool refuses a call without a target // instead of silently reading the main session. func TestMissingArgumentIsRejected(t *testing.T) { t.Parallel() h := &fakeHistory{sessions: []history.SessionMeta{{ID: history.MainSessionID}}} tool := newReader(h, testPersonas()) for _, args := range []any{map[string]any{}, map[string]any{"sessionId": " "}, "not-an-object"} { if _, err := tool.Execute(ownerCtx(), args); err == nil { t.Fatalf("Execute(%#v) accepted the call, want an error", args) } } if h.listCalls != 0 { t.Fatalf("history was read %d times for argument-less calls, want 0", h.listCalls) } } // TestLimitIsClampedNotRejected locks the page bounds: an absent, non-numeric or // negative limit means the default 50, and a limit above the cap is clamped to 200 // (mirroring GET /sessions/{id}/messages) rather than refused as a bad argument. func TestLimitIsClampedNotRejected(t *testing.T) { t.Parallel() cases := []struct { name string args map[string]any want int }{ {"absent", map[string]any{"sessionId": history.MainSessionID}, DefaultLimit}, {"zero", map[string]any{"sessionId": history.MainSessionID, "limit": 0}, DefaultLimit}, {"negative", map[string]any{"sessionId": history.MainSessionID, "limit": -3}, DefaultLimit}, {"non-numeric", map[string]any{"sessionId": history.MainSessionID, "limit": "abc"}, DefaultLimit}, {"above-cap", map[string]any{"sessionId": history.MainSessionID, "limit": 5000}, MaxLimit}, {"in-range", map[string]any{"sessionId": history.MainSessionID, "limit": 7}, 7}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := &fakeHistory{sessions: []history.SessionMeta{{ID: history.MainSessionID}}} readText(t, newReader(h, testPersonas()), tc.args) if h.listLimit != tc.want { t.Fatalf("List limit = %d, want %d", h.listLimit, tc.want) } }) } } // TestBeforePagesStrictlyOlder is the end-to-end pagination check against the real // store: the first page is the newest window, and before= returns only strictly older messages — the same semantics as the REST // endpoint, so a client cursor and a tool cursor mean the same thing. func TestBeforePagesStrictlyOlder(t *testing.T) { t.Parallel() st, _ := newTestStore(t) ctx := context.Background() for i := 1; i <= 5; i++ { if err := st.Append(ctx, history.Message{ SessionID: history.MainSessionID, Role: history.RoleUser, Content: fmt.Sprintf("m%d", i), CreatedAt: at(21, i), }); err != nil { t.Fatalf("append m%d: %v", i, err) } } tool := newReader(st, testPersonas()) first := readText(t, tool, map[string]any{"sessionId": history.MainSessionID, "limit": 2}) for _, want := range []string{"m5", "m4"} { if !strings.Contains(first, want) { t.Fatalf("first page = %q, want it to contain %q", first, want) } } if strings.Contains(first, "m3") || !strings.Contains(first, "before="+itoa(at(21, 4))) { t.Fatalf("first page = %q, want only m5/m4 plus the cursor for m4's timestamp", first) } older := readText(t, tool, map[string]any{"sessionId": history.MainSessionID, "limit": 2, "before": at(21, 4)}) if strings.Contains(older, "m5") || strings.Contains(older, "m4") { t.Fatalf("second page = %q, want strictly older messages only", older) } for _, want := range []string{"m3", "m2"} { if !strings.Contains(older, want) { t.Fatalf("second page = %q, want it to contain %q", older, want) } } } // queryInt runs a one-value, one-int query (test helper). func queryInt(t *testing.T, conn *sql.DB, query string, args ...any) int64 { t.Helper() var n int64 if err := conn.QueryRow(query, args...).Scan(&n); err != nil { t.Fatalf("query %q: %v", query, err) } return n } // TestReadIsReadOnly is the end-to-end read-only proof of design/22 §8.1 约束 1 // against the real store: reading another session writes nothing — no message is // appended to either session, no session is created or deleted, and the target's // read cursor is not advanced (UnreadCount is unchanged). func TestReadIsReadOnly(t *testing.T) { t.Parallel() st, conn := newTestStore(t) ctx := context.Background() subID, err := st.NewSubSessionFor(ctx, "owner", "yuki") if err != nil { t.Fatalf("new sub session: %v", err) } for i := 1; i <= 3; i++ { if err := st.Append(ctx, history.Message{ SessionID: subID, Role: history.RoleAssistant, PersonaID: "yuki", Content: fmt.Sprintf("sub%d", i), CreatedAt: at(21, i), }); err != nil { t.Fatalf("append sub%d: %v", i, err) } } beforeReadAt := queryInt(t, conn, `SELECT read_at FROM chat_sessions WHERE id = ?`, subID) beforeMsgs := queryInt(t, conn, `SELECT COUNT(*) FROM chat_messages WHERE session_id = ?`, subID) beforeSessions := queryInt(t, conn, `SELECT COUNT(*) FROM chat_sessions`) tool := newReader(st, testPersonas()) if got := readText(t, tool, map[string]any{"sessionId": subID}); !strings.Contains(got, "sub1") { t.Fatalf("material = %q, want the sub session transcript", got) } if after := queryInt(t, conn, `SELECT read_at FROM chat_sessions WHERE id = ?`, subID); after != beforeReadAt { t.Fatalf("read_at = %d after the read, want it unchanged at %d", after, beforeReadAt) } if after := queryInt(t, conn, `SELECT COUNT(*) FROM chat_messages WHERE session_id = ?`, subID); after != beforeMsgs { t.Fatalf("sub session message count = %d, want unchanged at %d", after, beforeMsgs) } if after := queryInt(t, conn, `SELECT COUNT(*) FROM chat_messages WHERE session_id = ?`, history.MainSessionID); after != 0 { t.Fatalf("main session gained %d messages, want the material to stay out of the reading session's history", after) } if after := queryInt(t, conn, `SELECT COUNT(*) FROM chat_sessions`); after != beforeSessions { t.Fatalf("session count = %d, want unchanged at %d", after, beforeSessions) } } // TestReaderExposesNoWriteMethod is the type-level half of the read-only guarantee: // the interfaces this package depends on declare no write verb at all, so there is // no Append/Delete/Rename/Mark/Publish method for any code path here to call. A // future edit that widened HistoryReader with a write call would fail this test. func TestReaderExposesNoWriteMethod(t *testing.T) { t.Parallel() writeVerbs := []string{"Append", "Delete", "Rename", "Mark", "Set", "Update", "Publish", "Write", "Broadcast", "Save", "Insert"} for _, iface := range []reflect.Type{ reflect.TypeOf((*HistoryReader)(nil)).Elem(), reflect.TypeOf((*PersonaNamer)(nil)).Elem(), } { methods := make([]string, 0, iface.NumMethod()) for i := 0; i < iface.NumMethod(); i++ { methods = append(methods, iface.Method(i).Name) } if len(methods) == 0 { t.Fatalf("%s declares no method at all", iface) } for _, name := range methods { for _, verb := range writeVerbs { if strings.HasPrefix(name, verb) { t.Errorf("%s.%s looks like a write; this package must depend on reads only", iface, name) } } } } } // TestHistoryFailureIsWrapped asserts a store failure surfaces as an error wrapping // the cause instead of being rendered as empty material. func TestHistoryFailureIsWrapped(t *testing.T) { t.Parallel() sentinel := errors.New("boom") h := &fakeHistory{ sessions: []history.SessionMeta{{ID: history.MainSessionID}}, listErr: sentinel, } _, err := newReader(h, testPersonas()).Execute(ownerCtx(), map[string]any{"sessionId": history.MainSessionID}) if !errors.Is(err, sentinel) { t.Fatalf("err = %v, want it to wrap %v", err, sentinel) } sessions := &fakeHistory{sessionsErr: sentinel} if _, err := newReader(sessions, testPersonas()).Execute(ownerCtx(), map[string]any{"sessionId": history.MainSessionID}); !errors.Is(err, sentinel) { t.Fatalf("err = %v, want it to wrap %v", err, sentinel) } } // TestNoHistoryInjected asserts a tool built without a history reader fails fast // rather than fabricating material. func TestNoHistoryInjected(t *testing.T) { t.Parallel() _, err := New(WithPersonas(testPersonas())).Execute(ownerCtx(), map[string]any{"sessionId": history.MainSessionID}) if !errors.Is(err, ErrNoHistory) { t.Fatalf("err = %v, want ErrNoHistory", err) } }