oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "bytes"
5 "errors"
6 "strings"
7 "testing"
8
9 "gorm.io/gorm"
10)
11
12func TestCreateSheetMakesAFirstTab(t *testing.T) {
13 app := newTestApp(t)
14 s, err := app.createSheet("Budget", testMember(t, app))
15 if err != nil {
16 t.Fatalf("createSheet: %v", err)
17 }
18 if s.ID == 0 {
19 t.Fatal("createSheet returned id 0")
20 }
21 if s.TabID == 0 {
22 t.Fatal("createSheet made a sheet with no tab; there would be nothing to open")
23 }
24 if s.Title != "Budget" {
25 t.Fatalf("title = %q, want %q", s.Title, "Budget")
26 }
27 if !app.tabBelongsTo(s.ID, s.TabID) {
28 t.Fatal("the tab createSheet returned does not belong to the sheet it returned")
29 }
30}
31
32// The one rule, at the only layer that could break it for a title.
33func TestSheetTitleIsSealedAtRest(t *testing.T) {
34 app := newTestApp(t)
35 const secret = "Salaries 2027"
36 if _, err := app.createSheet(secret, testMember(t, app)); err != nil {
37 t.Fatalf("createSheet: %v", err)
38 }
39 var raw []byte
40 // Row().Scan, not Raw().Scan: GORM reads a []byte destination on
41 // Scan as a slice of rows to fill, not as one blob column.
42 if err := app.db.Raw("SELECT title_sealed FROM sheets LIMIT 1").Row().Scan(&raw); err != nil {
43 t.Fatalf("read back: %v", err)
44 }
45 if len(raw) == 0 {
46 t.Fatal("no sealed title was stored")
47 }
48 if bytes.Contains(raw, []byte(secret)) {
49 t.Fatal("the sheet title reached the database in plaintext")
50 }
51}
52
53func TestTabNameIsSealedAtRest(t *testing.T) {
54 app := newTestApp(t)
55 if _, err := app.createSheet("Budget", testMember(t, app)); err != nil {
56 t.Fatalf("createSheet: %v", err)
57 }
58 var raw []byte
59 if err := app.db.Raw("SELECT name_sealed FROM tabs LIMIT 1").Row().Scan(&raw); err != nil {
60 t.Fatalf("read back: %v", err)
61 }
62 if bytes.Contains(raw, []byte(firstTabName)) {
63 t.Fatal("the tab name reached the database in plaintext; a predictable name is still content")
64 }
65}
66
67// This is the scope.Owned trap AGENTS.md names. The wrong
68// implementation returns one row and reads as a slightly short list
69// rather than as an error.
70//
71// Team standing, not creation, is what makes both rows visible: one
72// instance per team means a colleague reaches every sheet. A guest is
73// the other half of §8.2 and has its own suite in grants_test.go.
74func TestListSheetsIsTeamWideNotPerCreator(t *testing.T) {
75 app := newTestApp(t)
76 ada, err := app.memberFor("ada@example.com")
77 if err != nil {
78 t.Fatalf("memberFor ada: %v", err)
79 }
80 bob, err := app.memberFor("bob@example.com")
81 if err != nil {
82 t.Fatalf("memberFor bob: %v", err)
83 }
84 if _, err := app.createSheet("Ada's", ada); err != nil {
85 t.Fatalf("createSheet ada: %v", err)
86 }
87 if _, err := app.createSheet("Bob's", bob); err != nil {
88 t.Fatalf("createSheet bob: %v", err)
89 }
90 got, err := app.listSheets(ada)
91 if err != nil {
92 t.Fatalf("listSheets: %v", err)
93 }
94 if len(got) != 2 {
95 t.Fatalf("listSheets returned %d sheets, want 2 — sheets are team-shared", len(got))
96 }
97 titles := map[string]bool{}
98 for _, s := range got {
99 titles[s.Title] = true
100 }
101 if !titles["Ada's"] || !titles["Bob's"] {
102 t.Fatalf("listSheets returned %v, want both creators' sheets", titles)
103 }
104}
105
106func TestCreateSheetRefusesAnEmptyTitle(t *testing.T) {
107 app := newTestApp(t)
108 m := testMember(t, app)
109 if _, err := app.createSheet(" ", m); err == nil {
110 t.Fatal("createSheet with a blank title = nil error, want a refusal")
111 }
112 if _, err := app.createSheet("", m); err == nil {
113 t.Fatal(`createSheet("") = nil error, want a refusal`)
114 }
115}
116
117func TestCreateSheetTruncatesAnOverlongTitle(t *testing.T) {
118 app := newTestApp(t)
119 long := strings.Repeat("é", maxTitleRunes+50)
120 s, err := app.createSheet(long, testMember(t, app))
121 if err != nil {
122 t.Fatalf("createSheet: %v", err)
123 }
124 // Runes, not bytes: a multi-byte title truncated by byte count
125 // would slice a character in half.
126 if got := len([]rune(s.Title)); got != maxTitleRunes {
127 t.Fatalf("title is %d runes, want %d", got, maxTitleRunes)
128 }
129}
130
131func TestSheetSummaryForAMissingSheetIsNotFound(t *testing.T) {
132 app := newTestApp(t)
133 _, err := app.sheetSummary(999999, testMember(t, app))
134 // errNoAccess, not ErrRecordNotFound: the grant check runs first,
135 // and a sheet nobody can reach is indistinguishable from one that
136 // is not there. Handlers turn both into the same 404.
137 if !errors.Is(err, errNoAccess) && !errors.Is(err, gorm.ErrRecordNotFound) {
138 t.Fatalf("sheetSummary of a missing sheet = %v, want a not-found-shaped error", err)
139 }
140}
141
142func TestTabBelongsToRefusesACrossSheetPairing(t *testing.T) {
143 app := newTestApp(t)
144 m := testMember(t, app)
145 one, err := app.createSheet("One", m)
146 if err != nil {
147 t.Fatalf("createSheet one: %v", err)
148 }
149 two, err := app.createSheet("Two", m)
150 if err != nil {
151 t.Fatalf("createSheet two: %v", err)
152 }
153 if app.tabBelongsTo(one.ID, two.TabID) {
154 t.Fatal("a tab from another sheet was accepted; the sheet id in the URL would mean nothing")
155 }
156 if !app.tabBelongsTo(two.ID, two.TabID) {
157 t.Fatal("a tab was rejected from its own sheet")
158 }
159}
160