store: meetings, links, grants, pending joins, standing
Sealed titles, one-404 link lookup, replace and end in transactions, idempotent grants expiring per ruling 4, keyed-fingerprint pending rows bound to their link, standing computed per request.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 files changed,
+675
−14
internal/meet/app.go+34 −9internal/meet/ids.go+45 −0internal/meet/ids_test.go+28 −0internal/meet/standing.go+40 −0internal/meet/store.go+291 −0internal/meet/store_test.go+134 −0internal/meettest/harness_test.go+103 −5
diff --git a/internal/meet/app.go b/internal/meet/app.go| index cdbad22..4d16443 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "log/slog" |
| "net/http" |
| + "time" |
| "github.com/go-chi/chi/v5" |
| @@ -19,29 +20,35 @@ type app struct { |
| db *db.DB |
| cfg resolved |
| sealer *seal.Sealer |
| + store *store |
| logger *slog.Logger |
| } |
| -// App wires the whole app: refusals, schema, router, static files. It |
| -// returns a *http.ServeMux because rastrillo.Options.Mux is typed that |
| -// way; the chi router mounts inside it. |
| -func App(d *db.DB, cfg Config) (*http.ServeMux, error) { |
| +// SetClockForTest replaces the store's clock; the harness uses it to |
| +// expire grants and pending rows without sleeping. |
| +func (a *app) SetClockForTest(now func() time.Time) { a.store.now = now } |
| + |
| +// build wires the whole app: refusals, schema, router, static files. It |
| +// returns the *app alongside the mux so tests can reach into it (see |
| +// NewForTest); production code uses App, which discards the *app. |
| +func build(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| rc, err := resolve(cfg) |
| if err != nil { |
| - return nil, err |
| + return nil, nil, err |
| } |
| sealer, err := seal.New(rc.dek) |
| if err != nil { |
| - return nil, err |
| + return nil, nil, err |
| } |
| migrated, err := migrate.Apply(context.Background(), d, BootSchema) |
| if err != nil { |
| - return nil, err |
| + return nil, nil, err |
| } |
| rc.Logger.Info("migrate", "applied", migrated.Applied, |
| "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| - a := &app{db: d, cfg: rc, sealer: sealer, logger: rc.Logger} |
| + st := &store{g: d.G, sealer: sealer, now: time.Now} |
| + a := &app{db: d, cfg: rc, sealer: sealer, store: st, logger: rc.Logger} |
| r := chi.NewRouter() |
| r.Use(csrf.Protect(rc.Origin)) |
| @@ -50,5 +57,23 @@ func App(d *db.DB, cfg Config) (*http.ServeMux, error) { |
| mux := http.NewServeMux() |
| mux.Handle("GET /static/", assets.Handler()) |
| mux.Handle("/", r) |
| - return mux, nil |
| + return mux, a, nil |
| +} |
| + |
| +// App wires the whole app: refusals, schema, router, static files. It |
| +// returns a *http.ServeMux because rastrillo.Options.Mux is typed that |
| +// way; the chi router mounts inside it. |
| +func App(d *db.DB, cfg Config) (*http.ServeMux, error) { |
| + mux, _, err := build(d, cfg) |
| + return mux, err |
| +} |
| + |
| +// NewForTest builds the app and returns the handler and a clock setter. |
| +// Tests only; production uses App. |
| +func NewForTest(d *db.DB, cfg Config) (*http.ServeMux, func(func() time.Time), error) { |
| + mux, a, err := build(d, cfg) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + return mux, a.SetClockForTest, nil |
| } |
diff --git a/internal/meet/ids.go b/internal/meet/ids.go| new file mode 100644 |
| index 0000000..edf570d |
| --- /dev/null |
| +++ b/internal/meet/ids.go |
| @@ -0,0 +1,45 @@ |
| +package meet |
| + |
| +import ( |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "fmt" |
| +) |
| + |
| +// sqidAlphabet is Docs' and Slides' alphabet, character for character: |
| +// no 0/O, no 1/l/I, so a link read aloud survives. |
| +const sqidAlphabet = "23456789abcdefghjkmnpqrstuvwxyz" |
| +const sqidLength = 10 |
| +const maxSqidAttempts = 5 |
| + |
| +func newSqid() (string, error) { |
| + raw := make([]byte, sqidLength) |
| + if _, err := rand.Read(raw); err != nil { |
| + return "", fmt.Errorf("meet: generate sqid: %w", err) |
| + } |
| + out := make([]byte, sqidLength) |
| + for i, b := range raw { |
| + out[i] = sqidAlphabet[int(b)%len(sqidAlphabet)] |
| + } |
| + return string(out), nil |
| +} |
| + |
| +const linkTokenBytes = 32 |
| + |
| +// newLinkToken mints a token and its hash. The token is returned |
| +// exactly once, to be shown exactly once (ruling 16). |
| +func newLinkToken() (token, hash string, err error) { |
| + buf := make([]byte, linkTokenBytes) |
| + if _, err := rand.Read(buf); err != nil { |
| + return "", "", err |
| + } |
| + token = base64.RawURLEncoding.EncodeToString(buf) |
| + return token, hashLinkToken(token), nil |
| +} |
| + |
| +func hashLinkToken(token string) string { |
| + sum := sha256.Sum256([]byte(token)) |
| + return hex.EncodeToString(sum[:]) |
| +} |
diff --git a/internal/meet/ids_test.go b/internal/meet/ids_test.go| new file mode 100644 |
| index 0000000..b1c41f7 |
| --- /dev/null |
| +++ b/internal/meet/ids_test.go |
| @@ -0,0 +1,28 @@ |
| +package meet |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestSqidUsesOnlyTheReadableAlphabet(t *testing.T) { |
| + for i := 0; i < 50; i++ { |
| + s, err := newSqid() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(s) != sqidLength || strings.Trim(s, sqidAlphabet) != "" { |
| + t.Fatalf("bad sqid %q", s) |
| + } |
| + } |
| +} |
| + |
| +func TestLinkTokenHashIsSHA256Hex(t *testing.T) { |
| + tok, hash, err := newLinkToken() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(hash) != 64 || hashLinkToken(tok) != hash { |
| + t.Fatalf("hash %q does not match token", hash) |
| + } |
| +} |
diff --git a/internal/meet/standing.go b/internal/meet/standing.go| index d060486..e00a96a 100644 |
| --- a/internal/meet/standing.go |
| +++ b/internal/meet/standing.go |
| @@ -1,6 +1,7 @@ |
| package meet |
| import ( |
| + "context" |
| "fmt" |
| "strings" |
| ) |
| @@ -41,3 +42,42 @@ func resolveAllowlist(cfg Config) (allowlist, error) { |
| } |
| return allowlist{}, fmt.Errorf("meet: $MEET_ALLOWED_ADDRESSES is empty and Env is %q, not %q; refusing to start with no team", cfg.Env, EnvDevelopment) |
| } |
| + |
| +// Standing is a person's entry authority toward a meeting (spec §2.3). |
| +// Computed, never stored. |
| +type Standing int |
| + |
| +const ( |
| + StandingNone Standing = iota |
| + StandingGuest |
| + StandingMember |
| +) |
| + |
| +func (s Standing) String() string { |
| + switch s { |
| + case StandingMember: |
| + return "member" |
| + case StandingGuest: |
| + return "guest" |
| + } |
| + return "none" |
| +} |
| + |
| +// isMember is the lone-shape roster read: the allowlist. |
| +func (a *app) isMember(p Person) bool { |
| + return a.cfg.allow.addrs != nil && a.cfg.allow.allows(p.Email) |
| +} |
| + |
| +func (a *app) standingFor(ctx context.Context, p Person, m Meeting) (Standing, error) { |
| + if a.isMember(p) { |
| + return StandingMember, nil |
| + } |
| + g, ok, err := a.store.grantFor(ctx, m.ID, p.ID) |
| + if err != nil { |
| + return StandingNone, err |
| + } |
| + if ok && g.ExpiresAt.After(a.store.now()) { |
| + return StandingGuest, nil |
| + } |
| + return StandingNone, nil |
| +} |
diff --git a/internal/meet/store.go b/internal/meet/store.go| new file mode 100644 |
| index 0000000..0c8904c |
| --- /dev/null |
| +++ b/internal/meet/store.go |
| @@ -0,0 +1,291 @@ |
| +package meet |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + "gorm.io/gorm/clause" |
| + |
| + "amadan.net/oficina/meet/internal/meet/seal" |
| +) |
| + |
| +// grantLife is ruling 4: a guest grant expires two hours after the |
| +// meeting's start, or two hours after creation when there is no start. |
| +const grantLife = 2 * time.Hour |
| + |
| +// pendingLife matches the pending-join cookie: fifteen minutes. |
| +const pendingLife = 15 * time.Minute |
| + |
| +// store is every database access in the app. Sealing happens here and |
| +// nowhere else, so a handler never holds a plaintext title it did not |
| +// get from the store. |
| +type store struct { |
| + g *gorm.DB |
| + sealer *seal.Sealer |
| + now func() time.Time |
| +} |
| + |
| +func actorFor(p Person) string { return fmt.Sprintf("person:%d", p.ID) } |
| + |
| +// ensurePerson reads first and writes only when the read is empty: |
| +// this runs on every signed-in request, and SQLite has one writer. |
| +func (s *store) ensurePerson(ctx context.Context, address string) (Person, error) { |
| + email := normalizeAddress(address) |
| + if email == "" { |
| + return Person{}, errors.New("meet: cannot map an empty address to a person") |
| + } |
| + var found Person |
| + err := s.g.WithContext(ctx).Where("email = ?", email).Take(&found).Error |
| + switch { |
| + case err == nil: |
| + return found, nil |
| + case !errors.Is(err, gorm.ErrRecordNotFound): |
| + return Person{}, fmt.Errorf("meet: load person: %w", err) |
| + } |
| + name := email |
| + if at := strings.Index(email, "@"); at > 0 { |
| + name = email[:at] |
| + } |
| + created := Person{Email: email, Name: name, CreatedAt: s.now().UTC()} |
| + if res := s.g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&created); res.Error != nil { |
| + return Person{}, fmt.Errorf("meet: create person: %w", res.Error) |
| + } |
| + if err := s.g.WithContext(ctx).Where("email = ?", email).Take(&found).Error; err != nil { |
| + return Person{}, fmt.Errorf("meet: load person: %w", err) |
| + } |
| + return found, nil |
| +} |
| + |
| +type meetingView struct { |
| + Meeting |
| + Title string |
| + LinkPresent bool |
| +} |
| + |
| +func (s *store) title(m Meeting) string { |
| + t, err := s.sealer.OpenString(seal.PurposeMeetingTitle, m.TitleSealed) |
| + if err != nil { |
| + return "(title unreadable)" |
| + } |
| + return t |
| +} |
| + |
| +// createMeeting creates the meeting and its first link in one |
| +// transaction and returns the token, the only time it exists in |
| +// plaintext on the server side. |
| +func (s *store) createMeeting(ctx context.Context, title string, startsAt *time.Time, actor string) (Meeting, string, error) { |
| + sealed, err := s.sealer.SealString(seal.PurposeMeetingTitle, title) |
| + if err != nil { |
| + return Meeting{}, "", err |
| + } |
| + token, hash, err := newLinkToken() |
| + if err != nil { |
| + return Meeting{}, "", err |
| + } |
| + var m Meeting |
| + err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + for attempt := 0; ; attempt++ { |
| + sqid, err := newSqid() |
| + if err != nil { |
| + return err |
| + } |
| + m = Meeting{Sqid: sqid, TitleSealed: sealed, StartsAt: startsAt, CreatedBy: actor, CreatedAt: s.now().UTC()} |
| + res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&m) |
| + if res.Error != nil { |
| + return res.Error |
| + } |
| + if res.RowsAffected == 1 { |
| + break |
| + } |
| + if attempt+1 >= maxSqidAttempts { |
| + return errors.New("meet: could not mint a unique sqid") |
| + } |
| + } |
| + return tx.Create(&MeetingLink{MeetingID: m.ID, TokenHash: hash, CreatedBy: actor, CreatedAt: s.now().UTC()}).Error |
| + }) |
| + if err != nil { |
| + return Meeting{}, "", err |
| + } |
| + return m, token, nil |
| +} |
| + |
| +func (s *store) listMeetings(ctx context.Context) ([]meetingView, error) { |
| + var ms []Meeting |
| + if err := s.g.WithContext(ctx).Order("ended_at IS NOT NULL, starts_at IS NULL, starts_at ASC, created_at DESC").Find(&ms).Error; err != nil { |
| + return nil, err |
| + } |
| + out := make([]meetingView, 0, len(ms)) |
| + for _, m := range ms { |
| + var n int64 |
| + if err := s.g.WithContext(ctx).Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", m.ID).Count(&n).Error; err != nil { |
| + return nil, err |
| + } |
| + out = append(out, meetingView{Meeting: m, Title: s.title(m), LinkPresent: n > 0}) |
| + } |
| + return out, nil |
| +} |
| + |
| +func (s *store) meetingBySqid(ctx context.Context, sqid string) (Meeting, bool, error) { |
| + var m Meeting |
| + err := s.g.WithContext(ctx).Where("sqid = ?", sqid).Take(&m).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return Meeting{}, false, nil |
| + } |
| + return m, err == nil, err |
| +} |
| + |
| +// liveLink answers one false for a token that never existed, one that |
| +// was revoked, and one whose meeting has ended (spec §3.2's one 404). |
| +func (s *store) liveLink(ctx context.Context, token string) (MeetingLink, Meeting, bool, error) { |
| + if token == "" { |
| + return MeetingLink{}, Meeting{}, false, nil |
| + } |
| + var l MeetingLink |
| + err := s.g.WithContext(ctx).Where("token_hash = ?", hashLinkToken(token)).Take(&l).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return MeetingLink{}, Meeting{}, false, nil |
| + } |
| + if err != nil { |
| + return MeetingLink{}, Meeting{}, false, err |
| + } |
| + return s.liveLinkRow(ctx, l) |
| +} |
| + |
| +func (s *store) liveLinkByID(ctx context.Context, id int64) (MeetingLink, Meeting, bool, error) { |
| + var l MeetingLink |
| + err := s.g.WithContext(ctx).Where("id = ?", id).Take(&l).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return MeetingLink{}, Meeting{}, false, nil |
| + } |
| + if err != nil { |
| + return MeetingLink{}, Meeting{}, false, err |
| + } |
| + return s.liveLinkRow(ctx, l) |
| +} |
| + |
| +func (s *store) liveLinkRow(ctx context.Context, l MeetingLink) (MeetingLink, Meeting, bool, error) { |
| + if l.RevokedAt != nil { |
| + return MeetingLink{}, Meeting{}, false, nil |
| + } |
| + var m Meeting |
| + if err := s.g.WithContext(ctx).Where("id = ?", l.MeetingID).Take(&m).Error; err != nil { |
| + return MeetingLink{}, Meeting{}, false, err |
| + } |
| + if m.EndedAt != nil { |
| + return MeetingLink{}, Meeting{}, false, nil |
| + } |
| + return l, m, true, nil |
| +} |
| + |
| +// replaceLink revokes every live link for the meeting and mints one |
| +// new one, in one transaction, returning the new token once. |
| +func (s *store) replaceLink(ctx context.Context, meetingID int64, actor string) (string, error) { |
| + token, hash, err := newLinkToken() |
| + if err != nil { |
| + return "", err |
| + } |
| + now := s.now().UTC() |
| + err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + if err := tx.Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", meetingID).Update("revoked_at", now).Error; err != nil { |
| + return err |
| + } |
| + return tx.Create(&MeetingLink{MeetingID: meetingID, TokenHash: hash, CreatedBy: actor, CreatedAt: now}).Error |
| + }) |
| + if err != nil { |
| + return "", err |
| + } |
| + return token, nil |
| +} |
| + |
| +// endMeeting sets ended_at and revokes every link. |
| +func (s *store) endMeeting(ctx context.Context, meetingID int64, actor string) error { |
| + now := s.now().UTC() |
| + return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + res := tx.Model(&Meeting{}).Where("id = ? AND ended_at IS NULL", meetingID). |
| + Select("ended_at", "ended_by").Updates(map[string]any{"ended_at": now, "ended_by": actor}) |
| + if res.Error != nil { |
| + return res.Error |
| + } |
| + return tx.Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", meetingID).Update("revoked_at", now).Error |
| + }) |
| +} |
| + |
| +func (s *store) grantFor(ctx context.Context, meetingID, personID int64) (GuestGrant, bool, error) { |
| + var g GuestGrant |
| + err := s.g.WithContext(ctx).Where("meeting_id = ? AND person_id = ?", meetingID, personID).Take(&g).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return GuestGrant{}, false, nil |
| + } |
| + return g, err == nil, err |
| +} |
| + |
| +// mintGrant is idempotent: following the link twice touches nothing. |
| +func (s *store) mintGrant(ctx context.Context, m Meeting, personID int64) (GuestGrant, error) { |
| + if g, ok, err := s.grantFor(ctx, m.ID, personID); err != nil || ok { |
| + return g, err |
| + } |
| + now := s.now().UTC() |
| + base := now |
| + if m.StartsAt != nil { |
| + base = m.StartsAt.UTC() |
| + } |
| + g := GuestGrant{MeetingID: m.ID, PersonID: personID, GrantedAt: now, ExpiresAt: base.Add(grantLife)} |
| + if res := s.g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&g); res.Error != nil { |
| + return GuestGrant{}, res.Error |
| + } |
| + g, _, err := s.grantFor(ctx, m.ID, personID) |
| + return g, err |
| +} |
| + |
| +func (s *store) fingerprint(address string) (string, error) { |
| + return s.sealer.Fingerprint(seal.PurposePendingAddress, normalizeAddress(address)) |
| +} |
| + |
| +func (s *store) writePending(ctx context.Context, address string, link MeetingLink) error { |
| + fp, err := s.fingerprint(address) |
| + if err != nil { |
| + return err |
| + } |
| + now := s.now().UTC() |
| + return s.g.WithContext(ctx).Create(&PendingJoin{ |
| + MeetingID: link.MeetingID, LinkID: link.ID, AddressFingerprint: fp, |
| + CreatedAt: now, ExpiresAt: now.Add(pendingLife), |
| + }).Error |
| +} |
| + |
| +// pendingLive is the Authorize half: does this address hold an |
| +// unexpired pending row whose link is still live? |
| +func (s *store) pendingLive(ctx context.Context, address string) (bool, error) { |
| + p, ok, err := s.latestPending(ctx, address) |
| + if err != nil || !ok { |
| + return false, err |
| + } |
| + _, _, live, err := s.liveLinkByID(ctx, p.LinkID) |
| + return live, err |
| +} |
| + |
| +func (s *store) latestPending(ctx context.Context, address string) (PendingJoin, bool, error) { |
| + fp, err := s.fingerprint(address) |
| + if err != nil { |
| + return PendingJoin{}, false, err |
| + } |
| + var p PendingJoin |
| + err = s.g.WithContext(ctx).Where("address_fingerprint = ? AND expires_at > ?", fp, s.now().UTC()). |
| + Order("created_at DESC").Take(&p).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return PendingJoin{}, false, nil |
| + } |
| + return p, err == nil, err |
| +} |
| + |
| +func (s *store) deletePending(ctx context.Context, address string) error { |
| + fp, err := s.fingerprint(address) |
| + if err != nil { |
| + return err |
| + } |
| + return s.g.WithContext(ctx).Where("address_fingerprint = ?", fp).Delete(&PendingJoin{}).Error |
| +} |
diff --git a/internal/meet/store_test.go b/internal/meet/store_test.go| new file mode 100644 |
| index 0000000..1fe5b3e |
| --- /dev/null |
| +++ b/internal/meet/store_test.go |
| @@ -0,0 +1,134 @@ |
| +package meet |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "io" |
| + "log/slog" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + |
| + "amadan.net/oficina/meet/internal/meet/seal" |
| +) |
| + |
| +func testStore(t *testing.T) (*store, *time.Time) { |
| + t.Helper() |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + d, err := db.Open(filepath.Join(t.TempDir(), "s.db"), logger) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil { |
| + t.Fatal(err) |
| + } |
| + sl, _ := seal.New(bytes.Repeat([]byte{9}, 32)) |
| + now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC) |
| + return &store{g: d.G, sealer: sl, now: func() time.Time { return now }}, &now |
| +} |
| + |
| +func TestCreateMeetingSealsTitleAndMintsOneLiveLink(t *testing.T) { |
| + s, _ := testStore(t) |
| + ctx := context.Background() |
| + m, token, err := s.createMeeting(ctx, "Board meeting", nil, "person:1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if bytes.Contains(m.TitleSealed, []byte("Board")) { |
| + t.Fatal("title stored in plaintext") |
| + } |
| + if s.title(m) != "Board meeting" { |
| + t.Fatalf("title opened as %q", s.title(m)) |
| + } |
| + l, m2, ok, err := s.liveLink(ctx, token) |
| + if err != nil || !ok || m2.ID != m.ID || l.MeetingID != m.ID { |
| + t.Fatalf("live link not found: ok=%v err=%v", ok, err) |
| + } |
| + if _, _, ok, _ := s.liveLink(ctx, "nope"); ok { |
| + t.Fatal("unknown token answered live") |
| + } |
| +} |
| + |
| +func TestReplaceLinkRevokesTheOldOne(t *testing.T) { |
| + s, _ := testStore(t) |
| + ctx := context.Background() |
| + m, old, _ := s.createMeeting(ctx, "x", nil, "person:1") |
| + fresh, err := s.replaceLink(ctx, m.ID, "person:1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, ok, _ := s.liveLink(ctx, old); ok { |
| + t.Fatal("old link still live") |
| + } |
| + if _, _, ok, _ := s.liveLink(ctx, fresh); !ok { |
| + t.Fatal("new link not live") |
| + } |
| +} |
| + |
| +func TestEndMeetingKillsItsLinks(t *testing.T) { |
| + s, _ := testStore(t) |
| + ctx := context.Background() |
| + m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1") |
| + if err := s.endMeeting(ctx, m.ID, "person:1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, ok, _ := s.liveLink(ctx, tok); ok { |
| + t.Fatal("link live after end") |
| + } |
| +} |
| + |
| +func TestGrantExpiresTwoHoursAfterStartOrCreation(t *testing.T) { |
| + s, now := testStore(t) |
| + ctx := context.Background() |
| + start := now.Add(3 * time.Hour) |
| + scheduled, _, _ := s.createMeeting(ctx, "later", &start, "person:1") |
| + immediate, _, _ := s.createMeeting(ctx, "now", nil, "person:1") |
| + g1, _ := s.mintGrant(ctx, scheduled, 7) |
| + g2, _ := s.mintGrant(ctx, immediate, 7) |
| + if !g1.ExpiresAt.Equal(start.Add(grantLife)) { |
| + t.Errorf("scheduled grant expires %v, want %v", g1.ExpiresAt, start.Add(grantLife)) |
| + } |
| + if !g2.ExpiresAt.Equal(now.Add(grantLife)) { |
| + t.Errorf("immediate grant expires %v, want %v", g2.ExpiresAt, now.Add(grantLife)) |
| + } |
| + again, _ := s.mintGrant(ctx, immediate, 7) |
| + if again.ID != g2.ID { |
| + t.Fatal("minting twice made a second grant") |
| + } |
| +} |
| + |
| +func TestPendingIsKeyedFingerprintAndFollowsItsLink(t *testing.T) { |
| + s, now := testStore(t) |
| + ctx := context.Background() |
| + m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1") |
| + l, _, _, _ := s.liveLink(ctx, tok) |
| + if err := s.writePending(ctx, "Guest@Example.com", l); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Row(), not Scan(): gorm's own Scan treats a []byte destination as |
| + // a slice of per-row uint8 results, not one column's raw bytes. |
| + var raw []byte |
| + if err := s.g.Raw("SELECT address_fingerprint FROM pending_joins").Row().Scan(&raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + if bytes.Contains(bytes.ToLower(raw), []byte("guest@")) { |
| + t.Fatal("address stored in plaintext") |
| + } |
| + if live, _ := s.pendingLive(ctx, "guest@example.com"); !live { |
| + t.Fatal("pending not live") |
| + } |
| + if _, err := s.replaceLink(ctx, m.ID, "person:1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if live, _ := s.pendingLive(ctx, "guest@example.com"); live { |
| + t.Fatal("pending still live after its link was replaced") |
| + } |
| + *now = now.Add(pendingLife + time.Minute) |
| + if _, ok, _ := s.latestPending(ctx, "guest@example.com"); ok { |
| + t.Fatal("expired pending returned") |
| + } |
| +} |
diff --git a/internal/meettest/harness_test.go b/internal/meettest/harness_test.go| index e6a161b..03ce7bd 100644 |
| --- a/internal/meettest/harness_test.go |
| +++ b/internal/meettest/harness_test.go |
| @@ -14,8 +14,11 @@ import ( |
| "net/http/httptest" |
| "net/url" |
| "path/filepath" |
| + "regexp" |
| "strings" |
| + "sync" |
| "testing" |
| + "time" |
| "amadan.net/rastrillo/rastrillo/db" |
| @@ -26,9 +29,55 @@ import ( |
| // same-origin evidence — the pair a browser form submission would be. |
| const testOrigin = "http://app.test" |
| -// newApp builds the whole app per test over a fresh temp database, |
| -// exactly as main.go does. |
| -func newApp(t *testing.T) http.Handler { |
| +// harness is the whole app over a fresh temp database, plus the |
| +// clock and mailer hooks tests need to drive time and read magic |
| +// links without a real mail server. |
| +type harness struct { |
| + h http.Handler |
| + db *db.DB |
| + mail *recordingMailer |
| + clock func(func() time.Time) |
| + now time.Time |
| +} |
| + |
| +func (x *harness) advance(d time.Duration) { |
| + x.now = x.now.Add(d) |
| + now := x.now |
| + x.clock(func() time.Time { return now }) |
| +} |
| + |
| +type recordingMailer struct { |
| + mu sync.Mutex |
| + to []string |
| + links []string |
| +} |
| + |
| +var magicLink = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`) |
| + |
| +func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) error { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + m.to = append(m.to, to) |
| + m.links = append(m.links, magicLink.FindString(body)) |
| + return nil |
| +} |
| + |
| +func (m *recordingMailer) last() (string, string) { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + if len(m.to) == 0 { |
| + return "", "" |
| + } |
| + return m.to[len(m.to)-1], m.links[len(m.links)-1] |
| +} |
| + |
| +func (m *recordingMailer) count() int { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + return len(m.to) |
| +} |
| + |
| +func newHarness(t *testing.T, members ...string) *harness { |
| t.Helper() |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| @@ -36,13 +85,22 @@ func newApp(t *testing.T) http.Handler { |
| t.Fatal(err) |
| } |
| t.Cleanup(func() { d.Close() }) |
| - h, err := meet.App(d, meet.Config{Env: meet.EnvDevelopment, Origin: testOrigin, Logger: logger}) |
| + rec := &recordingMailer{} |
| + mux, setClock, err := meet.NewForTest(d, meet.Config{ |
| + Env: meet.EnvDevelopment, Origin: testOrigin, AllowedAddresses: members, Mailer: rec, Logger: logger, |
| + }) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - return h |
| + x := &harness{h: mux, db: d, mail: rec, clock: setClock, now: time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)} |
| + x.advance(0) |
| + return x |
| } |
| +// newApp builds the whole app per test over a fresh temp database, |
| +// exactly as main.go does. |
| +func newApp(t *testing.T) http.Handler { return newHarness(t).h } |
| + |
| func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { |
| t.Helper() |
| rec := httptest.NewRecorder() |
| @@ -62,6 +120,46 @@ func post(t *testing.T, h http.Handler, target string, form url.Values) *httptes |
| return rec |
| } |
| +// client is a cookie-carrying browser stand-in: cookies set on one |
| +// response are carried on the next request, and a MaxAge<0 clear |
| +// (sign-out) drops them again. Used from Task 4 on. |
| +type client struct { |
| + t *testing.T |
| + x *harness |
| + cookies map[string]*http.Cookie |
| +} |
| + |
| +func (x *harness) browser(t *testing.T) *client { |
| + return &client{t: t, x: x, cookies: map[string]*http.Cookie{}} |
| +} |
| + |
| +func (c *client) do(req *http.Request) *httptest.ResponseRecorder { |
| + for _, ck := range c.cookies { |
| + req.AddCookie(ck) |
| + } |
| + rec := httptest.NewRecorder() |
| + c.x.h.ServeHTTP(rec, req) |
| + for _, ck := range rec.Result().Cookies() { |
| + if ck.MaxAge < 0 { |
| + delete(c.cookies, ck.Name) |
| + continue |
| + } |
| + c.cookies[ck.Name] = ck |
| + } |
| + return rec |
| +} |
| + |
| +func (c *client) get(target string) *httptest.ResponseRecorder { |
| + return c.do(httptest.NewRequest(http.MethodGet, target, nil)) |
| +} |
| + |
| +func (c *client) post(target string, form url.Values) *httptest.ResponseRecorder { |
| + req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", testOrigin) |
| + return c.do(req) |
| +} |
| + |
| type nopMailer struct{} |
| func (nopMailer) Send(ctx context.Context, to, subject, body string) error { return nil } |