oficina / meet Public

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>
Paul Campbell pushed by paul@keymail.dev ef4ff05fa69f0d2deeceda70c58a007a8dc813eb parent 3f7e4c7
7 files changed, +675 −14
  • internal/meet/app.go +34 −9
  • internal/meet/ids.go +45 −0
  • internal/meet/ids_test.go +28 −0
  • internal/meet/standing.go +40 −0
  • internal/meet/store.go +291 −0
  • internal/meet/store_test.go +134 −0
  • internal/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 }