| 1 | package sheets |
| 2 | |
| 3 | // roleFor is the single place both sources of access to a sheet are |
| 4 | // decided — an explicit grant, and team standing — so it is also the |
| 5 | // only place the "does this sheet exist for anybody" question has to |
| 6 | // be asked. These cover the two ways it can be wrong. |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "strconv" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "amadan.net/oficina/sheets/internal/sheets/store" |
| 17 | ) |
| 18 | |
| 19 | // TestAFillingSheetIsClosedToItsOwnImporter is the blocker the |
| 20 | // follow-up review found: every import mints an explicit owner grant |
| 21 | // for the person who started it, and roleFor used to hand back that |
| 22 | // grant's role without ever asking whether the sheet was one anybody |
| 23 | // could open. The grid API and the live stream gate on roleFor alone, |
| 24 | // so that was direct read and write access to a sheet no list shows. |
| 25 | func TestAFillingSheetIsClosedToItsOwnImporter(t *testing.T) { |
| 26 | app, cookies := signIn(t) |
| 27 | importer := signedInMember(t, app) |
| 28 | |
| 29 | s, err := app.createFillingSheet("half a workbook", importer) |
| 30 | if err != nil { |
| 31 | t.Fatalf("createFillingSheet: %v", err) |
| 32 | } |
| 33 | if _, has := app.roleFor(s.ID, importer); has { |
| 34 | t.Error("the importer holds a role on a sheet that is still filling") |
| 35 | } |
| 36 | |
| 37 | sheetID := strconv.FormatUint(s.ID, 10) |
| 38 | tabID := strconv.FormatUint(s.TabID, 10) |
| 39 | base := "/api/sheets/" + sheetID + "/tabs/" + tabID |
| 40 | |
| 41 | for _, tc := range []struct{ name, path string }{ |
| 42 | {"grid cells", base + "/cells"}, |
| 43 | {"live stream", base + "/stream"}, |
| 44 | } { |
| 45 | // A deadline, because the stream route is the one that does NOT |
| 46 | // return when it lets you in: refused it 404s at once, allowed |
| 47 | // it holds the connection open by design. Without this the |
| 48 | // regression shape is a test that hangs until CI gives up, |
| 49 | // which is a worse signal than a red line. Measured: removing |
| 50 | // the roleFor check turns this from a 404 into a hang. |
| 51 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) |
| 52 | req := as(httptest.NewRequest(http.MethodGet, tc.path, nil), cookies).WithContext(ctx) |
| 53 | rec := do(t, app, req) |
| 54 | cancel() |
| 55 | if rec.Code != http.StatusNotFound { |
| 56 | t.Errorf("%s answered %d on a filling sheet, want 404", tc.name, rec.Code) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // Once filled it is ordinary again, so this is a gate and not a |
| 61 | // wall. |
| 62 | if err := app.markFilled(s.ID); err != nil { |
| 63 | t.Fatalf("markFilled: %v", err) |
| 64 | } |
| 65 | if _, has := app.roleFor(s.ID, importer); !has { |
| 66 | t.Error("the importer lost their role on a sheet that finished filling") |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // TestADeletedSheetIsClosedToAGrantHolder is the same hole in the shape |
| 71 | // it had before import existed: a grant outlived the sheet it pointed |
| 72 | // at, and roleFor honoured it. |
| 73 | func TestADeletedSheetIsClosedToAGrantHolder(t *testing.T) { |
| 74 | app := newTestApp(t) |
| 75 | owner := testMember(t, app) |
| 76 | |
| 77 | s, err := app.createSheet("a sheet about to go", owner) |
| 78 | if err != nil { |
| 79 | t.Fatalf("createSheet: %v", err) |
| 80 | } |
| 81 | if _, has := app.roleFor(s.ID, owner); !has { |
| 82 | t.Fatal("the owner holds no role on a live sheet; fixture is wrong") |
| 83 | } |
| 84 | |
| 85 | now := time.Now().UTC() |
| 86 | if err := app.db.Model(&store.Sheet{}).Where("id = ?", s.ID). |
| 87 | Update("deleted_at", now).Error; err != nil { |
| 88 | t.Fatalf("soft delete: %v", err) |
| 89 | } |
| 90 | if _, has := app.roleFor(s.ID, owner); has { |
| 91 | t.Error("a grant holder keeps their role on a deleted sheet") |
| 92 | } |
| 93 | } |
| 94 | |