| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "errors" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "gorm.io/gorm" |
| 10 | ) |
| 11 | |
| 12 | func 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. |
| 33 | func 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 | |
| 53 | func 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. |
| 74 | func 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 | |
| 106 | func 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 | |
| 117 | func 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 | |
| 131 | func 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 | |
| 142 | func 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 | |