~bigbes/sr-ht-dolt

ref: 60fdb6dd03bcfe04a5448b24ee3a5db8fd9ebce8 sr-ht-dolt/mcpsrv/mcpsrv_test.go -rw-r--r-- 38.8 KiB
60fdb6dd — Eugene Blikh beads: show the stored rows behind an issue on its detail pane 5 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
package mcpsrv_test

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strconv"
	"testing"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"

	"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
	"sourcecraft.dev/bigbes/sr-ht-dolt/db"
	"sourcecraft.dev/bigbes/sr-ht-dolt/mcpsrv"
)

// The tool half of the suite: what the surface answers, driven through a real
// in-process MCP client over fakes — no Postgres, no store on disk. The
// transport half (who the answer is computed for, and what a request refused
// before a handler looks like) is http_test.go.

// testOrigin is what [dolt.sr.ht]origin says on the instance these tests
// pretend to be, and therefore the Host the allowlist admits.
const testOrigin = "https://dolt.example.org"

// --- the fixture ------------------------------------------------------------
//
// One owner with one database of every shape that matters, because the two
// things this surface has to get right are both properties of a *set*: which
// databases a caller may list, and what a listing says about one it cannot fully
// read.

const (
	aliceID = 1 // the owner of every fixture database
	bobID   = 2 // a grantee: an ACL entry on the private one and nothing else
	carolID = 3 // a stranger: an account with no relationship to any of them
)

// storePath is the on-disk store dir of a database, as db/ stores it on the
// row. It never leaves the server (nothing in a tool result carries it), which
// is why the fakes key on it rather than on a name.
func storePath(owner, name string) string { return "/stores/~" + owner + "/" + name }

type fixture struct {
	name       string
	visibility core.Visibility
	acl        map[int]core.AccessMode

	// session is what opening this database's store yields. A nil one is a store
	// that will not open at all, which is the entry a listing has to survive.
	session *fakeSession
}

func fixtures() []fixture {
	return []fixture{
		{
			// The database the paging tools are measured against: more rows than
			// read_rows' cap and more commits than get_commit_log's.
			name:       "notes",
			visibility: core.VisibilityPublic,
			session:    notesStore(),
		},
		{
			// The one a beads-aware tool will answer about: its tables carry the
			// fingerprint beads.Applies looks for.
			name:       "tracker",
			visibility: core.VisibilityPublic,
			session:    smallStore("main", "bbbb", beadsTables()),
		},
		{
			// A store created by a repository that has never been pushed to.
			name:       "empty",
			visibility: core.VisibilityPublic,
			session:    &fakeSession{},
		},
		{
			// A store this daemon cannot open. It is in the fixture rather than in
			// one test of its own because the property worth pinning is that it
			// does not take the rest of the listing with it.
			name:       "broken",
			visibility: core.VisibilityPublic,
			session:    nil,
		},
		{
			name:       "drafts",
			visibility: core.VisibilityUnlisted,
			session:    smallStore("main", "dddd", []fakeTable{newTable("drafts", 3)}),
		},
		{
			name:       "secrets",
			visibility: core.VisibilityPrivate,
			acl:        map[int]core.AccessMode{bobID: core.AccessRO},
			session:    smallStore("release", "eeee", []fakeTable{newTable("secrets", 3), newTable("keys", 1)}),
		},
	}
}

// headTime is the commit time every fixture head carries, fixed so that a test
// can assert the value rather than merely that something was rendered.
var headTime = time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)

// The size of the "notes" database, chosen so that both caps of
// docs/DESIGN.mcp.md §9.3 are exceeded: a page that stops short is the ordinary
// case here rather than a contrived one, and the totals are prime-ish numbers no
// cap or default divides evenly, so an off-by-a-page is visible.
const (
	notesRows    = 1200
	notesCommits = 150
)

// notesStore is a database with two branches, a linear history and two tables.
//
// Its head commit is "aaaa" at headTime, because that is what the list_databases
// suite asserts about it; everything else about it exists for the tools of
// ch. 9.1.
func notesStore() *fakeSession {
	tables := []fakeTable{newTable("notes", notesRows), newTable("tags", 3)}

	commits := make([]browse.CommitInfo, notesCommits)
	for i := range commits {
		hash := fmt.Sprintf("c%03d", i)
		if i == 0 {
			hash = "aaaa"
		}
		commits[i] = browse.CommitInfo{
			Hash:    hash,
			Author:  "alice",
			Date:    headTime.Add(-time.Duration(i) * time.Hour),
			Message: fmt.Sprintf("commit %d", i),
		}
	}
	for i := range commits[:len(commits)-1] {
		commits[i].ParentHashes = []string{commits[i+1].Hash}
	}

	diffs := map[string]*browse.CommitDiff{}
	for i, c := range commits {
		if i == len(commits)-1 {
			// The oldest commit has no parent, so browse compares it against the
			// empty root and every table reads as added.
			diffs[c.Hash] = initialDiff(c.Hash, tables)
			continue
		}
		diffs[c.Hash] = &browse.CommitDiff{
			Hash:   c.Hash,
			Tables: []browse.TableDiff{{Name: "notes", RowsAdded: 2, RowsModified: 1}},
		}
	}

	return &fakeSession{
		branches: []browse.Branch{{Name: "main", Head: "aaaa"}, {Name: "wip", Head: "c001"}},
		commits:  commits,
		tables:   tables,
		diffs:    diffs,
	}
}

// smallStore is a database of one branch and one commit — the initial one, so
// its diff has every table added — over the tables given.
func smallStore(branch, head string, tables []fakeTable) *fakeSession {
	return &fakeSession{
		branches: []browse.Branch{{Name: branch, Head: head}},
		commits: []browse.CommitInfo{{
			Hash: head, Author: "alice", Date: headTime, Message: "initial commit",
		}},
		tables: tables,
		diffs:  map[string]*browse.CommitDiff{head: initialDiff(head, tables)},
	}
}

// initialDiff is what browse.CommitSummary answers for a commit with no parent:
// it compares against the empty root, so every table shows as added with all of
// its rows. The rule is browse/'s and is tested there; this is a fixture that
// reproduces it, not a second implementation of it.
func initialDiff(hash string, tables []fakeTable) *browse.CommitDiff {
	d := &browse.CommitDiff{Hash: hash}
	for _, t := range tables {
		d.Tables = append(d.Tables, browse.TableDiff{
			Name:      t.name,
			Added:     true,
			RowsAdded: int64(len(t.rows)),
		})
	}
	return d
}

// fakeTable is one table of a fixture store: the schema browse would report and
// the rows behind it, kept consistent by construction — a test that compares a
// reported row count against the rows it can read cannot be satisfied by a fake
// that disagrees with itself.
type fakeTable struct {
	name string
	cols []browse.ColumnInfo
	rows [][]string

	// nulls is the NULL mask browse carries beside its rows, parallel to rows.
	// A table that leaves it unset holds a value in every cell, and pageOf builds
	// the all-false mask browse would have returned for it — a fixture never
	// answers a page without a mask, because the store never does either.
	nulls [][]bool
}

func (t fakeTable) info() browse.TableInfo {
	return browse.TableInfo{Name: t.name, Columns: t.cols, RowCount: uint64(len(t.rows))}
}

// columns is the display order browse.Rows produces: primary key first, then the
// rest. The fixture schemas below declare their columns in that order already.
func (t fakeTable) columns() []string {
	out := make([]string, 0, len(t.cols))
	for _, c := range t.cols {
		out = append(out, c.Name)
	}
	return out
}

// newTable builds a table of n rows over a two-column keyed schema.
func newTable(name string, n int) fakeTable {
	rows := make([][]string, n)
	for i := range rows {
		rows[i] = []string{strconv.Itoa(i), fmt.Sprintf("%s row %d", name, i)}
	}
	return fakeTable{
		name: name,
		cols: []browse.ColumnInfo{
			{Name: "id", Type: "int", PrimaryKey: true},
			{Name: "body", Type: "text", Nullable: true},
		},
		rows: rows,
	}
}

// beadsTables is the minimum beads.Applies accepts: issues + dependencies, with
// issues carrying id and status. The fingerprint is beads/'s and is not restated
// here — this is a fixture that satisfies it, not a second copy of it.
func beadsTables() []fakeTable {
	return []fakeTable{
		{
			name: "issues",
			cols: []browse.ColumnInfo{{Name: "id"}, {Name: "status"}, {Name: "title"}},
			rows: [][]string{{"bd-1", "open", "first"}, {"bd-2", "closed", "second"}},
		},
		{
			name: "dependencies",
			cols: []browse.ColumnInfo{{Name: "from_id"}, {Name: "to_id"}},
			rows: [][]string{{"bd-2", "bd-1"}},
		},
	}
}

// listable is the listing rule of docs/DESIGN.mcp.md §4.3 spelled out
// independently of the implementation under test: PUBLIC to everyone including
// anonymity, plus whatever the viewer owns or holds an ACL entry on. UNLISTED
// and PRIVATE are absent for everybody else.
//
// It is written here rather than derived from the fake so that the expectation
// and the fake cannot drift into agreement with each other and away from the
// rule.
func listable(f fixture, viewer *core.Caller) bool {
	if f.visibility == core.VisibilityPublic {
		return true
	}
	if viewer == nil {
		return false
	}
	if viewer.UserID == aliceID {
		return true // alice owns every fixture
	}
	_, ok := f.acl[viewer.UserID]
	return ok
}

func listableNames(viewer *core.Caller) []string {
	var out []string
	for _, f := range fixtures() {
		if listable(f, viewer) {
			out = append(out, f.name)
		}
	}
	return out
}

// --- the fakes --------------------------------------------------------------

// fakeRepos is the metadata store: the fixture rows, plus the two listing
// queries db/repos.go actually has and the ACL lookup. It applies db/'s SQL as
// Go, and nothing else — no visibility rule beyond the one ListReposByOwner
// documents, so a surface that leaned on the store to hide something would fail
// here rather than pass by accident.
type fakeRepos struct {
	repos []*core.Repo
	acl   map[int]map[int]core.AccessMode // repoID -> userID -> mode

	// listErr, when set, is what both listings answer: a metadata store that
	// could not be read.
	listErr error
}

var _ mcpsrv.Repos = (*fakeRepos)(nil)

func newFakeRepos() *fakeRepos {
	f := &fakeRepos{acl: map[int]map[int]core.AccessMode{}}
	for i, fx := range fixtures() {
		id := i + 1
		f.repos = append(f.repos, &core.Repo{
			ID:          id,
			Name:        fx.name,
			Description: "the " + fx.name + " database",
			OwnerID:     aliceID,
			OwnerName:   "alice",
			Path:        storePath("alice", fx.name),
			Visibility:  fx.visibility,
		})
		for userID, mode := range fx.acl {
			if f.acl[id] == nil {
				f.acl[id] = map[int]core.AccessMode{}
			}
			f.acl[id][userID] = mode
		}
	}
	return f
}

func (f *fakeRepos) GetRepoByOwnerAndName(_ context.Context, owner, name string) (*core.Repo, error) {
	for _, r := range f.repos {
		if r.OwnerName == owner && r.Name == name {
			return r, nil
		}
	}
	return nil, db.ErrNotFound
}

func (f *fakeRepos) ListReposByOwner(_ context.Context, owner string, viewer *core.Caller) ([]*core.Repo, error) {
	if f.listErr != nil {
		return nil, f.listErr
	}
	var out []*core.Repo
	for _, r := range f.repos {
		if r.OwnerName != owner {
			continue
		}
		visible := r.Visibility == core.VisibilityPublic
		if viewer != nil && (viewer.UserID == r.OwnerID || f.hasACL(r.ID, viewer.UserID)) {
			visible = true
		}
		if visible {
			out = append(out, r)
		}
	}
	return out, nil
}

// ListReposForViewer mirrors db.Store's instance-wide listing rule: it is
// ListReposByOwner's rule minus the owner filter, so PUBLIC is listed to
// everyone including anonymity and everything else only to its owner and its
// grantees.
//
// It lists in the order the fixtures were added, where db/ lists newest first.
// That difference is deliberate and harmless: what the aggregation's ceiling
// takes is the first N of whatever order this returns, and a fixture written in
// the order it is read is one a test can reason about.
func (f *fakeRepos) ListReposForViewer(_ context.Context, viewer *core.Caller) ([]*core.Repo, error) {
	if f.listErr != nil {
		return nil, f.listErr
	}
	var out []*core.Repo
	for _, r := range f.repos {
		visible := r.Visibility == core.VisibilityPublic
		if viewer != nil && (viewer.UserID == r.OwnerID || f.hasACL(r.ID, viewer.UserID)) {
			visible = true
		}
		if visible {
			out = append(out, r)
		}
	}
	return out, nil
}

func (f *fakeRepos) ListReposForDashboard(_ context.Context, userID int) ([]*core.Repo, error) {
	if f.listErr != nil {
		return nil, f.listErr
	}
	var out []*core.Repo
	for _, r := range f.repos {
		if r.OwnerID == userID || f.hasACL(r.ID, userID) {
			out = append(out, r)
		}
	}
	return out, nil
}

func (f *fakeRepos) EffectiveAccess(_ context.Context, userID, repoID int) (*core.AccessMode, error) {
	mode, ok := f.acl[repoID][userID]
	if !ok {
		return nil, nil
	}
	return &mode, nil
}

func (f *fakeRepos) hasACL(repoID, userID int) bool {
	_, ok := f.acl[repoID][userID]
	return ok
}

// fakeOpener resolves a store path to its fixture session. A path with no
// session is a store that will not open, which is how the "broken" fixture
// behaves.
type fakeOpener struct {
	sessions map[string]*fakeSession
	opened   []string
}

var _ mcpsrv.BrowseOpener = (*fakeOpener)(nil)

func newFakeOpener() *fakeOpener {
	o := &fakeOpener{sessions: map[string]*fakeSession{}}
	for _, fx := range fixtures() {
		if fx.session != nil {
			o.sessions[storePath("alice", fx.name)] = fx.session
		}
	}
	return o
}

func (o *fakeOpener) Open(_ context.Context, diskPath string) (mcpsrv.BrowseSession, error) {
	o.opened = append(o.opened, diskPath)
	sess, ok := o.sessions[diskPath]
	if !ok {
		return nil, fmt.Errorf("no store at %s", diskPath)
	}
	return sess, nil
}

// fakeSession is one bare store as browse/ reads it: a branch list, a linear
// history newest-first, tables with rows, and one recorded diff per commit.
//
// It reproduces browse/'s *contract* rather than its implementation — a ref is a
// branch name or a commit hash, an unknown one is ErrRefNotFound, an unknown
// table is ErrTableNotFound, a page past the end is empty with the true total —
// because those are the behaviours the tools are written against. Anything it
// cannot answer fails loudly rather than returning an empty result that would
// read as an answer.
type fakeSession struct {
	branches []browse.Branch
	commits  []browse.CommitInfo // newest first, as browse.Log walks them
	tables   []fakeTable
	diffs    map[string]*browse.CommitDiff

	// configAt is the beads config table's contents *per ref* — key → value — for
	// the fixtures the memory revision walk runs over, and it takes precedence
	// over tables for that one table. Every other table in this fake is the same
	// at every ref, which is enough for the questions the other tools ask; the
	// memory walk is the one reading whose whole subject is how a table changed
	// between commits.
	//
	// It is keyed by the ref string as the caller names it — a branch name or a
	// commit hash — because that is what the walk passes and what a fixture is
	// written in. A ref that is absent from it has no config table there, which is
	// an ordinary answer while walking back past the commit that created it.
	configAt map[string]map[string]string

	// configHash is that table's content hash per ref, which is what makes the
	// walk cheap: equal hashes across a step mean the newer commit wrote no
	// memory and it is skipped without a single row read. An absent or empty entry
	// is a table that does not exist at that ref, exactly as browse reports one.
	configHash map[string]string

	logErr    error
	tablesErr error
	rowsErr   error

	// The read counters, one per kind of read a session serves. They are what
	// makes a cache assertion a measurement rather than a stopwatch: the
	// head-hash gate of the cross-database ready set is "the second call reads no
	// rows", and that is counted here and never timed.
	rowReads   int
	tableReads int
	logReads   int

	closes int
}

var _ mcpsrv.BrowseSession = (*fakeSession)(nil)

func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, nil }

// Log walks the history from ref's head, or from a cursor, and reports the hash
// of the commit after the page — which is how browse says "there is more".
func (s *fakeSession) Log(_ context.Context, refStr, fromHash string, limit int) ([]browse.CommitInfo, string, error) {
	s.logReads++
	if s.logErr != nil {
		return nil, "", s.logErr
	}
	if limit <= 0 {
		// browse.Log's own guard. The tools cap and default before they call, so
		// reaching this is a bug in the tool rather than a caller's argument.
		return nil, "", fmt.Errorf("fake: log limit must be positive, got %d", limit)
	}

	start := -1
	if fromHash != "" {
		// browse.Log wraps ErrRefNotFound around a from-hash that does not parse
		// and one that parses but names no commit alike; this fake has no notion
		// of "parses" at all, so both collapse onto the same lookup miss here,
		// which is the same sentinel either way.
		if start = s.indexOf(fromHash); start < 0 {
			return nil, "", fmt.Errorf("%w: %s", browse.ErrRefNotFound, fromHash)
		}
	} else {
		start = s.indexOf(s.resolve(refStr))
	}
	if start < 0 {
		return nil, "", fmt.Errorf("%w: %s", browse.ErrRefNotFound, refStr)
	}

	end, next := start+limit, ""
	if end < len(s.commits) {
		next = s.commits[end].Hash
	} else {
		end = len(s.commits)
	}
	return append([]browse.CommitInfo(nil), s.commits[start:end]...), next, nil
}

func (s *fakeSession) Tables(_ context.Context, refStr string) ([]browse.TableInfo, error) {
	s.tableReads++
	if s.tablesErr != nil {
		return nil, s.tablesErr
	}
	if s.resolve(refStr) == "" {
		return nil, fmt.Errorf("%w: %s", browse.ErrRefNotFound, refStr)
	}
	out := make([]browse.TableInfo, 0, len(s.tables))
	for _, t := range s.tables {
		out = append(out, t.info())
	}
	return out, nil
}

// TableHash is read by the memory revision walk and by nothing else on this
// surface, so a fixture that declares no hashes is one no walk was meant to run
// over — and saying so is the point: answering "" instead would be read as "the
// config table is not there", which is a fact about the fixture the walk would
// then quietly build an answer on.
func (s *fakeSession) TableHash(_ context.Context, refStr, table string) (string, bool, error) {
	if s.configHash == nil {
		panic("TableHash: this fixture declares no table hashes, so no revision walk should reach it")
	}
	if table != memoryConfigTable {
		return "", false, nil
	}
	hash, ok := s.configHash[refStr]
	if !ok || hash == "" {
		return "", false, nil
	}
	return hash, true, nil
}

func (s *fakeSession) Rows(_ context.Context, refStr, table string, offset, limit int) (*browse.RowPage, error) {
	s.rowReads++
	if s.rowsErr != nil {
		return nil, s.rowsErr
	}
	if s.resolve(refStr) == "" {
		return nil, fmt.Errorf("%w: %s", browse.ErrRefNotFound, refStr)
	}
	if offset < 0 || limit <= 0 {
		return nil, fmt.Errorf("fake: bad page offset=%d limit=%d", offset, limit)
	}

	// The config table of a memory fixture is per ref, and a ref it does not name
	// has no such table there — the answer a walk gets past the commit that
	// created it.
	if s.configAt != nil && table == memoryConfigTable {
		rows, ok := s.configAt[refStr]
		if !ok {
			return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
		}
		return pageOf(configTable(rows), offset, limit), nil
	}

	for _, t := range s.tables {
		if t.name != table {
			continue
		}
		return pageOf(t, offset, limit), nil
	}
	return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
}

// pageOf is one page of a fixture table, with the total browse reports beside
// it: a page past the end is empty and still carries the true count.
func pageOf(t fakeTable, offset, limit int) *browse.RowPage {
	page := &browse.RowPage{
		Columns: t.columns(),
		Rows:    [][]string{},
		Nulls:   [][]bool{},
		Offset:  offset,
		Total:   len(t.rows),
	}
	if offset < len(t.rows) {
		end := min(offset+limit, len(t.rows))
		page.Rows = append(page.Rows, t.rows[offset:end]...)
		page.Nulls = append(page.Nulls, t.mask(offset, end)...)
	}
	return page
}

// mask is the NULL mask of rows [lo, hi), sliced in lockstep with them. A table
// that declared none holds no NULL anywhere, so the mask is all false — the
// shape browse returns for such a table, rather than an absent one.
func (t fakeTable) mask(lo, hi int) [][]bool {
	if t.nulls != nil {
		if len(t.nulls) != len(t.rows) {
			panic("fake: the NULL mask of " + t.name + " is not parallel to its rows")
		}
		return t.nulls[lo:hi]
	}
	out := make([][]bool, 0, hi-lo)
	for _, r := range t.rows[lo:hi] {
		out = append(out, make([]bool, len(r)))
	}
	return out
}

func (s *fakeSession) CommitSummary(_ context.Context, hashStr string) (*browse.CommitDiff, error) {
	hash := s.resolve(hashStr)
	if hash == "" {
		return nil, fmt.Errorf("%w: %s", browse.ErrRefNotFound, hashStr)
	}
	diff, ok := s.diffs[hash]
	if !ok {
		// A commit that exists always has a summary in browse; a fixture missing
		// one is a gap in the fixture, and saying so beats answering "no tables
		// changed".
		return nil, fmt.Errorf("fake: no recorded diff for %s", hash)
	}
	return diff, nil
}

func (s *fakeSession) Close() error {
	s.closes++
	return nil
}

// resolve maps a ref — a branch name or a commit hash, which is what
// browse.resolveCommit accepts — to a commit hash, or "" when it is neither.
func (s *fakeSession) resolve(refStr string) string {
	for _, b := range s.branches {
		if b.Name == refStr {
			return b.Head
		}
	}
	if s.indexOf(refStr) >= 0 {
		return refStr
	}
	return ""
}

func (s *fakeSession) indexOf(hash string) int {
	for i, c := range s.commits {
		if c.Hash == hash {
			return i
		}
	}
	return -1
}

// --- callers ----------------------------------------------------------------

// The three principals of the visibility matrix, as the *auth.AuthContext every
// plane of this service produces. Anonymous is a nil one, and it is a caller
// like any other.
func alice() *auth.AuthContext { return user(aliceID, "alice") }
func bob() *auth.AuthContext   { return user(bobID, "bob") }
func carol() *auth.AuthContext { return user(carolID, "carol") }

func user(id int, name string) *auth.AuthContext {
	return &auth.AuthContext{UserID: id, Username: name, UserType: auth.USER_TYPE_USER}
}

// authnContext is a context carrying ac as the resolved caller, exactly as the
// credential middleware leaves it — authn.WithCaller stores a nil one as-is and
// CallerFromContext reads it back as anonymous, which is why an anonymous test
// needs no special case.
func authnContext(ac *auth.AuthContext) context.Context {
	return authn.WithCaller(context.Background(), ac)
}

func coreCaller(ac *auth.AuthContext) *core.Caller {
	if ac == nil {
		return nil
	}
	return &core.Caller{UserID: ac.UserID, Username: ac.Username, UserType: core.UserType(ac.UserType)}
}

// --- plumbing ---------------------------------------------------------------

// newServer builds the surface over the fakes, failing the test on a wiring
// error rather than returning one.
func newServer(t *testing.T, repos mcpsrv.Repos, opener mcpsrv.BrowseOpener) *mcpsrv.Server {
	t.Helper()
	s, err := mcpsrv.New(repos, opener, nil, testOrigin)
	require.NoError(t, err)
	return s
}

// connect runs an in-process MCP client against the real server, with ac as the
// caller.
//
// The caller is injected by connecting the session with a context carrying it,
// which is exactly what happens in production: the SDK connects a session with
// the context of the HTTP request, and every tool handler descends from it. Over
// HTTP the credential middleware puts it there (http_test.go drives that path);
// here the test puts it there directly, and the handlers cannot tell.
func connect(t *testing.T, s *mcpsrv.Server, ac *auth.AuthContext) *mcp.ClientSession {
	t.Helper()
	ctx := authnContext(ac)

	serverTransport, clientTransport := mcp.NewInMemoryTransports()
	serverConn, err := s.Connect(ctx, serverTransport)
	require.NoError(t, err)
	t.Cleanup(func() { _ = serverConn.Close() })

	client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "test"}, nil)
	session, err := client.Connect(context.Background(), clientTransport, nil)
	require.NoError(t, err)
	t.Cleanup(func() { _ = session.Close() })
	return session
}

// call makes a tool call, failing the test on a protocol error — which is the
// distinction errors.go draws: a missing database is a result, a store that
// could not answer is a protocol error, and a test that conflated them would
// pass for the wrong reason.
func call(t *testing.T, s *mcp.ClientSession, name string, args map[string]any) *mcp.CallToolResult {
	t.Helper()
	res, err := s.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
	require.NoError(t, err, "protocol-level failure calling %s", name)
	return res
}

// decode reads a successful tool result into out, asserting it is not an error
// result on the way.
func decode(t *testing.T, res *mcp.CallToolResult, out any) {
	t.Helper()
	require.False(t, res.IsError, "unexpected tool error: %s", errorText(res))
	require.NotNil(t, res.StructuredContent, "no structured output")
	raw, err := json.Marshal(res.StructuredContent)
	require.NoError(t, err)
	require.NoError(t, json.Unmarshal(raw, out))
}

// errorText is the message of an error result, which is where this surface's
// refusals are written.
func errorText(res *mcp.CallToolResult) string {
	var s string
	for _, c := range res.Content {
		if tc, ok := c.(*mcp.TextContent); ok {
			s += tc.Text
		}
	}
	return s
}

// resultJSON is the whole result as it went over the wire — content blocks
// included, not just the structured half — for the tests that assert about
// everything a client can see.
func resultJSON(t *testing.T, res *mcp.CallToolResult) string {
	t.Helper()
	raw, err := json.Marshal(res)
	require.NoError(t, err)
	return string(raw)
}

// The shapes a client decodes into, spelled out here rather than exported from
// the package: they are this surface's contract, and a test that reused the
// production structs would pass no matter what those structs said.
type (
	listDatabasesResult struct {
		Databases []databaseResult `json:"databases"`
	}

	databaseResult struct {
		Owner        string           `json:"owner"`
		Name         string           `json:"name"`
		Description  string           `json:"description"`
		Visibility   string           `json:"visibility"`
		Content      *databaseContent `json:"content"`
		ContentError string           `json:"content_error"`
	}

	databaseContent struct {
		DefaultBranch string     `json:"default_branch"`
		Head          string     `json:"head"`
		HeadTime      *time.Time `json:"head_time"`
		IsBeads       bool       `json:"is_beads"`
	}
)

func names(res listDatabasesResult) []string {
	out := make([]string, 0, len(res.Databases))
	for _, d := range res.Databases {
		out = append(out, d.Name)
	}
	return out
}

func listDatabases(t *testing.T, session *mcp.ClientSession, args map[string]any) listDatabasesResult {
	t.Helper()
	var out listDatabasesResult
	decode(t, call(t, session, "list_databases", args), &out)
	return out
}

// --- the constructor --------------------------------------------------------

func TestNewRejectsAMissingSeam(t *testing.T) {
	opener := newFakeOpener()
	repos := newFakeRepos()

	_, err := mcpsrv.New(nil, opener, nil, testOrigin)
	require.Error(t, err, "a surface with no metadata store would answer every call internal error")
	assert.Contains(t, err.Error(), "Repos")

	_, err = mcpsrv.New(repos, nil, nil, testOrigin)
	require.Error(t, err, "a surface that cannot open a store cannot describe one")
	assert.Contains(t, err.Error(), "BrowseOpener")
}

// A nil InstanceValidator is a configuration and not a missing seam: an instance
// with no [tokens.sr.ht] section still serves meta PATs and anonymous callers
// (authn.ResolveBearer's documented contract, docs/DESIGN.mcp.md §10).
func TestNewAcceptsNoTokensDaemon(t *testing.T) {
	s, err := mcpsrv.New(newFakeRepos(), newFakeOpener(), nil, testOrigin)
	require.NoError(t, err)
	require.NotNil(t, s)

	session := connect(t, s, nil)
	assert.NotEmpty(t, listDatabases(t, session, map[string]any{"owner": "alice"}).Databases,
		"an instance without a token daemon still answers an anonymous caller")
}

// The Host allowlist is derived from the origin, so an origin with no host is a
// constructor error rather than a guessed "localhost" — which would make every
// malformed origin agree with a local client on the one code path that decides
// the allowlist.
func TestNewRequiresAnOriginToGuardWith(t *testing.T) {
	for _, origin := range []string{"", "   ", "not a url", "/relative/path"} {
		t.Run(fmt.Sprintf("%q", origin), func(t *testing.T) {
			_, err := mcpsrv.New(newFakeRepos(), newFakeOpener(), nil, origin)
			require.Error(t, err)
			assert.Contains(t, err.Error(), "no host")
		})
	}
}

// --- the tool ---------------------------------------------------------------

func TestServerAdvertisesTheToolsOfTheseChapters(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	require.Equal(t, mcpsrv.ServerName, session.InitializeResult().ServerInfo.Name)

	res, err := session.ListTools(context.Background(), nil)
	require.NoError(t, err)

	var got []string
	for _, tool := range res.Tools {
		got = append(got, tool.Name)
		assert.NotEmpty(t, tool.Description, "%s: an agent reads the description as its documentation", tool.Name)
		require.NotNil(t, tool.Annotations, "%s: every tool here is a read and must say so", tool.Name)
		assert.True(t, tool.Annotations.ReadOnlyHint, "%s", tool.Name)
		require.NotNil(t, tool.InputSchema, "%s: the schema is derived from the Go struct", tool.Name)
	}
	// The generic surface of docs/DESIGN.mcp.md §9.1 and the beads-aware tools of
	// §9.2, whole — ready_work included, which completes the chapter. This list
	// is where an accidental extra — or a tool that quietly stopped being
	// registered — is visible.
	assert.ElementsMatch(t, []string{
		"list_databases",
		"list_branches",
		"list_tables",
		"read_rows",
		"get_commit_log",
		"get_commit_diff",
		"list_issues",
		"get_issue",
		"list_milestones",
		"list_memories",
		"ready_work",
	}, got)
}

func TestListDatabasesDescribesADatabase(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	byName := map[string]databaseResult{}
	for _, d := range listDatabases(t, session, map[string]any{"owner": "alice"}).Databases {
		byName[d.Name] = d
	}

	notes := byName["notes"]
	assert.Equal(t, "alice", notes.Owner)
	assert.Equal(t, "the notes database", notes.Description)
	assert.Equal(t, string(core.VisibilityPublic), notes.Visibility)
	require.NotNil(t, notes.Content)
	assert.Equal(t, "main", notes.Content.DefaultBranch)
	assert.Equal(t, "aaaa", notes.Content.Head)
	require.NotNil(t, notes.Content.HeadTime)
	assert.True(t, headTime.Equal(*notes.Content.HeadTime), "got %v", notes.Content.HeadTime)
	assert.False(t, notes.Content.IsBeads, "a database whose tables are not beads' is not a tracker")

	tracker := byName["tracker"]
	require.NotNil(t, tracker.Content)
	assert.True(t, tracker.Content.IsBeads,
		"the fingerprint is beads.Applies, and this fixture satisfies it")
}

// A database that exists and carries no commits is not a failure: its content is
// null and there is no error beside it. The two nulls are distinguishable, which
// is the whole reason content is one object rather than four fields.
func TestADatabaseWithNoCommitsHasNoContentAndNoError(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	for _, d := range listDatabases(t, session, map[string]any{"owner": "alice"}).Databases {
		if d.Name != "empty" {
			continue
		}
		assert.Nil(t, d.Content, "nothing has been pushed, so there is nothing to describe")
		assert.Empty(t, d.ContentError, "and that is not an error")
		return
	}
	t.Fatal("the empty database was not listed")
}

// One unreadable store costs that database its content and nothing else. The
// alternatives were failing the whole call — one broken store hiding every
// database from every caller — and answering is_beads:false, which is a lie an
// agent cannot detect.
func TestAnUnreadableStoreDoesNotSinkTheListing(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	res := listDatabases(t, session, map[string]any{"owner": "alice"})
	assert.Subset(t, names(res), []string{"notes", "tracker", "broken"},
		"the other databases are still listed")

	for _, d := range res.Databases {
		if d.Name != "broken" {
			continue
		}
		assert.Nil(t, d.Content, "no content is claimed for a store that did not open")
		assert.NotEmpty(t, d.ContentError, "and the entry says so")
		assert.Equal(t, string(core.VisibilityPublic), d.Visibility,
			"the metadata beside it came from Postgres and is still true")
		return
	}
	t.Fatal("the broken database was not listed")
}

// The failure reaches the caller as a fixed sentence: the real one names on-disk
// paths and dolt internals, and this endpoint is reachable by anyone.
func TestAContentFailureDisclosesNoDetail(t *testing.T) {
	repos := newFakeRepos()
	opener := newFakeOpener()
	opener.sessions[storePath("alice", "notes")].tablesErr = errors.New("read /srv/dolt/~alice/notes/manifest: input/output error")

	session := connect(t, newServer(t, repos, opener), nil)
	body := resultJSON(t, call(t, session, "list_databases", map[string]any{"owner": "alice"}))

	assert.NotContains(t, body, "/srv/dolt", "no on-disk path reaches a caller")
	assert.NotContains(t, body, "input/output error")
	assert.Contains(t, body, "could not be read")
}

// Every session a listing opens is closed by the handler that opened it, which
// is the browse discipline: a store held open across calls is a stale manifest
// and a leaked handle.
func TestEverySessionIsClosed(t *testing.T) {
	repos := newFakeRepos()
	opener := newFakeOpener()
	session := connect(t, newServer(t, repos, opener), alice())

	listDatabases(t, session, nil)

	for path, sess := range opener.sessions {
		assert.Equal(t, 1, sess.closes, "%s: opened once, closed once", path)
	}
}

// A metadata store that could not answer is a protocol error and not an empty
// listing: an agent must not read "Postgres is down" as "you have no
// databases".
func TestAStoreThatCouldNotAnswerIsAProtocolError(t *testing.T) {
	repos := newFakeRepos()
	repos.listErr = errors.New("dial tcp 127.0.0.1:5432: connection refused")
	session := connect(t, newServer(t, repos, newFakeOpener()), alice())

	res, err := session.CallTool(context.Background(), &mcp.CallToolParams{
		Name:      "list_databases",
		Arguments: map[string]any{"owner": "alice"},
	})
	require.Error(t, err, "a broken metadata store is not an answer")
	assert.NotContains(t, err.Error(), "5432", "and the cause is logged, not sent")
	assert.Nil(t, res)
}

// --- the visibility matrix --------------------------------------------------

// The listing rule of docs/DESIGN.mcp.md §4.3 over every viewer: PUBLIC to
// everyone, an owner sees all of their own, a grantee sees what they were
// granted, and an UNLISTED database of somebody else is absent from the listing
// while remaining readable by direct address.
func TestListDatabasesAppliesTheListingRule(t *testing.T) {
	server := newServer(t, newFakeRepos(), newFakeOpener())

	for _, tc := range []struct {
		name   string
		caller *auth.AuthContext
	}{
		{"anonymous", nil},
		{"a stranger", carol()},
		{"a grantee", bob()},
		{"the owner", alice()},
	} {
		t.Run(tc.name, func(t *testing.T) {
			res := listDatabases(t, connect(t, server, tc.caller), map[string]any{"owner": "alice"})
			assert.ElementsMatch(t, listableNames(coreCaller(tc.caller)), names(res))
		})
	}
}

// Nothing a caller may not list leaks through the listing — not as a name, not
// as a description, not in an error message. The private database's name is the
// canary: a stranger who can see the string at all can enumerate what exists.
func TestNothingLeaksToAViewerWhoMayNotList(t *testing.T) {
	server := newServer(t, newFakeRepos(), newFakeOpener())

	for _, tc := range []struct {
		name   string
		caller *auth.AuthContext
	}{
		{"anonymous", nil},
		{"a stranger", carol()},
	} {
		t.Run(tc.name, func(t *testing.T) {
			session := connect(t, server, tc.caller)
			body := resultJSON(t, call(t, session, "list_databases", map[string]any{"owner": "alice"}))

			assert.NotContains(t, body, "secrets", "a private database is not named to a caller who may not list it")
			assert.NotContains(t, body, "drafts", "and neither is an unlisted one")
		})
	}
}

// An owner named with the sigil is the address a link shows, so it is accepted
// rather than answered with a sentence about punctuation.
func TestTheOwnerArgumentToleratesTheSigil(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	with := listDatabases(t, session, map[string]any{"owner": "~alice"})
	without := listDatabases(t, session, map[string]any{"owner": "alice"})
	assert.Equal(t, names(without), names(with))
	assert.NotEmpty(t, names(with))
}

// An owner nobody has and an owner with nothing visible are one answer: an empty
// listing. That is not a limitation to be fixed — a "no such user" would let an
// agent enumerate accounts through a database listing.
func TestAnUnknownOwnerIsAnEmptyListing(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	res := call(t, session, "list_databases", map[string]any{"owner": "nobody"})
	assert.False(t, res.IsError, "not knowing an account is not an error")

	var out listDatabasesResult
	decode(t, res, &out)
	assert.Empty(t, out.Databases)
}

// Without an owner the tool answers about the caller: everything they own or
// hold an ACL entry on, whatever its visibility. That is the dashboard query and
// the only "everything I may see" this service can answer.
func TestListDatabasesWithNoOwnerIsTheCallersOwn(t *testing.T) {
	server := newServer(t, newFakeRepos(), newFakeOpener())

	t.Run("the owner sees all of their own", func(t *testing.T) {
		res := listDatabases(t, connect(t, server, alice()), nil)
		assert.ElementsMatch(t, listableNames(coreCaller(alice())), names(res))
	})

	t.Run("a grantee sees what they were granted", func(t *testing.T) {
		res := listDatabases(t, connect(t, server, bob()), nil)
		assert.Equal(t, []string{"secrets"}, names(res))
	})

	t.Run("a stranger owns nothing and is granted nothing", func(t *testing.T) {
		res := listDatabases(t, connect(t, server, carol()), nil)
		assert.Empty(t, res.Databases)
	})
}

// An anonymous caller with no owner named has nothing to be answered about:
// there is no "every public database on this instance" query (db/repos.go), so
// the tool says what it cannot do and names the two ways out instead of
// answering an empty listing that would read as "this instance is empty".
func TestAnonymousWithNoOwnerIsToldWhatToPass(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	res := call(t, session, "list_databases", nil)
	require.True(t, res.IsError, "an empty listing here would be a false statement about the instance")

	text := errorText(res)
	assert.Contains(t, text, "owner")
	assert.Contains(t, text, "token")
}