Preserve Home sessions on storage failures and pin reviewed adapter
10 files changed,
+233
−45
go.mod+1 −1go.sum+2 −2internal/slides/app.go+6 −8internal/slides/auth.go+11 −8internal/slides/config.go+1 −1internal/slides/handlers.go+5 −6internal/slides/home.go+25 −7internal/slides/home_test.go+175 −2internal/slides/presentation.go+5 −7internal/slides/render.go+2 −3
CI passed — run details
diff --git a/go.mod b/go.mod| index 8f3df20..5f37914 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,6 +3,7 @@ module amadan.net/oficina/slides |
| go 1.25.0 |
| require ( |
| + amadan.net/oficina/home/connect v0.0.0-20260909233352-868f189c5c77 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 |
| @@ -15,7 +16,6 @@ require ( |
| ) |
| require ( |
| - amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f // indirect |
| github.com/BurntSushi/toml v1.6.0 // indirect |
| github.com/chromedp/sysutil v1.1.0 // indirect |
| github.com/dustin/go-humanize v1.0.1 // indirect |
diff --git a/go.sum b/go.sum| index 646882f..401dab1 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,5 +1,5 @@ |
| -amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f h1:DLcotgutTVa/LeYiCTmBH+MO0O8EEJ2RN97+5zFxndE= |
| -amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f/go.mod h1:jsd1+E2JaW2m1Afqh7CHjSbdxYSRk2UPAEbxeSQ8zyo= |
| +amadan.net/oficina/home/connect v0.0.0-20260909233352-868f189c5c77 h1:eKlXkS4kkVxsFTDtGxnVj0+rfVc50alQLw8o6TeSRQM= |
| +amadan.net/oficina/home/connect v0.0.0-20260909233352-868f189c5c77/go.mod h1:jsd1+E2JaW2m1Afqh7CHjSbdxYSRk2UPAEbxeSQ8zyo= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
diff --git a/internal/slides/app.go b/internal/slides/app.go| index b2cda4b..d9987b4 100644 |
| --- a/internal/slides/app.go |
| +++ b/internal/slides/app.go |
| @@ -1,22 +1,20 @@ |
| package slides |
| import ( |
| - "amadan.net/oficina/home/connect" |
| - "amadan.net/oficina/home/handoff" |
| "context" |
| "net/http" |
| - "github.com/go-chi/chi/v5" |
| - |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/oficina/slides/internal/slides/core" |
| + "amadan.net/oficina/slides/internal/slides/live" |
| + "amadan.net/oficina/slides/internal/slides/present" |
| rauth "amadan.net/rastrillo/rastrillo/auth" |
| "amadan.net/rastrillo/rastrillo/csrf" |
| "amadan.net/rastrillo/rastrillo/db" |
| "amadan.net/rastrillo/rastrillo/jobs" |
| "amadan.net/rastrillo/rastrillo/migrate" |
| - |
| - "amadan.net/oficina/slides/internal/slides/core" |
| - "amadan.net/oficina/slides/internal/slides/live" |
| - "amadan.net/oficina/slides/internal/slides/present" |
| + "github.com/go-chi/chi/v5" |
| ) |
| // New wires the whole app: schema, sign-in, router, static files. It |
diff --git a/internal/slides/auth.go b/internal/slides/auth.go| index b018d5e..8df7734 100644 |
| --- a/internal/slides/auth.go |
| +++ b/internal/slides/auth.go |
| @@ -18,7 +18,6 @@ |
| package slides |
| import ( |
| - "amadan.net/oficina/home/connect" |
| "context" |
| "database/sql" |
| "errors" |
| @@ -29,15 +28,15 @@ import ( |
| "strings" |
| "time" |
| - "gorm.io/gorm" |
| - "gorm.io/gorm/clause" |
| - |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/connect/linkstore" |
| + "amadan.net/oficina/slides/internal/slides/present" |
| "amadan.net/rastrillo/rastrillo" |
| rauth "amadan.net/rastrillo/rastrillo/auth" |
| "amadan.net/rastrillo/rastrillo/mail" |
| "amadan.net/rastrillo/rastrillo/sessions" |
| - |
| - "amadan.net/oficina/slides/internal/slides/present" |
| + "gorm.io/gorm" |
| + "gorm.io/gorm/clause" |
| ) |
| // signinPath is where the sign-in page lives. It must keep matching |
| @@ -384,8 +383,12 @@ func (a *app) currentUser(w http.ResponseWriter, r *http.Request) (User, session |
| if s.Method == connect.HomeMethod { |
| u, err := a.homeUser(r, s) |
| if err != nil { |
| - a.revokeHome(w, r, s.Subject) |
| - return User{}, sessionOffboarded |
| + if errors.Is(err, linkstore.ErrRefused) || errors.Is(err, errHomeIdentity) { |
| + a.revokeHome(w, r, s.Subject) |
| + return User{}, sessionOffboarded |
| + } |
| + a.logger.Error("slides: resolve Home user", "err", err) |
| + return User{}, sessionNone |
| } |
| return u, sessionOK |
| } |
diff --git a/internal/slides/config.go b/internal/slides/config.go| index 867da56..85c0af4 100644 |
| --- a/internal/slides/config.go |
| +++ b/internal/slides/config.go |
| @@ -13,12 +13,12 @@ |
| package slides |
| import ( |
| - "amadan.net/oficina/home/connect" |
| "fmt" |
| "log/slog" |
| "net/url" |
| "strings" |
| + "amadan.net/oficina/home/connect" |
| "amadan.net/rastrillo/rastrillo/blobs" |
| "amadan.net/rastrillo/rastrillo/mail" |
| ) |
diff --git a/internal/slides/handlers.go b/internal/slides/handlers.go| index b499397..96698d5 100644 |
| --- a/internal/slides/handlers.go |
| +++ b/internal/slides/handlers.go |
| @@ -1,18 +1,17 @@ |
| package slides |
| import ( |
| - "amadan.net/oficina/home/connect" |
| - "amadan.net/rastrillo/rastrillo/sessions" |
| "context" |
| "log/slog" |
| - rauth "amadan.net/rastrillo/rastrillo/auth" |
| - "amadan.net/rastrillo/rastrillo/db" |
| - "amadan.net/rastrillo/rastrillo/jobs" |
| - |
| + "amadan.net/oficina/home/connect" |
| "amadan.net/oficina/slides/internal/slides/core" |
| "amadan.net/oficina/slides/internal/slides/live" |
| "amadan.net/oficina/slides/internal/slides/present" |
| + rauth "amadan.net/rastrillo/rastrillo/auth" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/jobs" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| ) |
| // app holds what every handler needs: the database, the logger, the |
diff --git a/internal/slides/home.go b/internal/slides/home.go| index a3ef86f..ee8728d 100644 |
| --- a/internal/slides/home.go |
| +++ b/internal/slides/home.go |
| @@ -3,7 +3,9 @@ package slides |
| import ( |
| "bytes" |
| "context" |
| + "database/sql" |
| "errors" |
| + "fmt" |
| "net/http" |
| "amadan.net/oficina/home/connect" |
| @@ -13,9 +15,10 @@ import ( |
| "amadan.net/rastrillo/rastrillo/csrf" |
| "amadan.net/rastrillo/rastrillo/migrate" |
| "amadan.net/rastrillo/rastrillo/sessions" |
| + "gorm.io/gorm" |
| ) |
| -var homeSchema = migrate.Merge(migrate.MustFromFS(handoff.Migrations, "home_handoff"), migrate.MustFromFS(linkstore.Migrations, "home_connect")) |
| +var homeSchema = migrate.Merge(migrate.MustFromFS(handoff.Migrations, "home_handoff"), migrate.MustFromFS(linkstore.Migrations, linkstore.SchemaNamespace)) |
| var errHomeIdentity = errors.New("slides: Home identity refused") |
| func neutralSession(s sessions.Session) connect.Session { |
| @@ -48,14 +51,23 @@ func (a *app) initHome(c *connect.DecodedCredentials) error { |
| return connect.Person{}, errHomeIdentity |
| } |
| var u User |
| - if err := a.db.G.WithContext(ctx).Where("email = ?", normalizeAddress(s.Subject)).Take(&u).Error; err != nil || !a.allowed.allows(u.Email) { |
| - return connect.Person{}, errHomeIdentity |
| + if err := a.db.G.WithContext(ctx).Where("email = ?", normalizeAddress(s.Subject)).Take(&u).Error; err != nil { |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return connect.Person{}, sql.ErrNoRows |
| + } |
| + return connect.Person{}, err |
| + } |
| + if !a.allowed.allows(u.Email) { |
| + return connect.Person{}, sql.ErrNoRows |
| } |
| return connect.Person{ID: u.ID, Label: u.Email}, nil |
| }, LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { |
| var email string |
| - if err := q.QueryRowContext(ctx, "SELECT email FROM users WHERE id = ?", id).Scan(&email); err != nil || !a.allowed.allows(email) { |
| - return "", errHomeIdentity |
| + if err := q.QueryRowContext(ctx, "SELECT email FROM users WHERE id = ?", id).Scan(&email); err != nil { |
| + return "", err |
| + } |
| + if !a.allowed.allows(email) { |
| + return "", sql.ErrNoRows |
| } |
| return email, nil |
| }, Render: a.renderHome}) |
| @@ -89,8 +101,14 @@ func (a *app) homeUser(r *http.Request, s sessions.Session) (User, error) { |
| return User{}, err |
| } |
| var u User |
| - if err := a.db.G.WithContext(r.Context()).Where("id = ?", person.ID).Take(&u).Error; err != nil || !a.allowed.allows(u.Email) { |
| - return User{}, errHomeIdentity |
| + if err := a.db.G.WithContext(r.Context()).Where("id = ?", person.ID).Take(&u).Error; err != nil { |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return User{}, linkstore.ErrRefused |
| + } |
| + return User{}, fmt.Errorf("%w: %v", linkstore.ErrStorage, err) |
| + } |
| + if !a.allowed.allows(u.Email) { |
| + return User{}, linkstore.ErrRefused |
| } |
| return u, nil |
| } |
diff --git a/internal/slides/home_test.go b/internal/slides/home_test.go| index 608df90..25a87a4 100644 |
| --- a/internal/slides/home_test.go |
| +++ b/internal/slides/home_test.go |
| @@ -1,11 +1,11 @@ |
| package slides |
| import ( |
| - "amadan.net/rastrillo/rastrillo/assertion" |
| "bytes" |
| "context" |
| "encoding/base64" |
| "encoding/json" |
| + "errors" |
| "net/http" |
| "net/http/httptest" |
| "net/url" |
| @@ -15,10 +15,13 @@ import ( |
| "time" |
| "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/connect/linkstore" |
| "amadan.net/oficina/home/handoff" |
| "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/assertion" |
| assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| "amadan.net/rastrillo/rastrillo/sessions" |
| + "gorm.io/gorm" |
| ) |
| const homeTestOrigin = "https://slides.example.com" |
| @@ -64,10 +67,38 @@ func homeSession(t *testing.T, a *app, subject, method string) *http.Cookie { |
| } |
| func homeLinked(t *testing.T, a *app, u User) { |
| t.Helper() |
| - if err := a.db.G.Exec("INSERT INTO home_identity_links(issuer,subject,person_id) VALUES(?,?,?)", a.cfg.Home.Issuer, "home-person", u.ID).Error; err != nil { |
| + ctx := context.Background() |
| + now := time.Now() |
| + c, err := connect.DecodeCredentials(a.cfg.Home, a.cfg.Origin) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + store, err := linkstore.New(a.db.Writer(), c.DEK, a.cfg.Origin, c.Issuer, "oficina/slides", func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { |
| + var address string |
| + err := q.QueryRowContext(ctx, "SELECT email FROM users WHERE id=?", id).Scan(&address) |
| + return address, err |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + local := linkstore.Local{PersonID: u.ID, SessionToken: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{17}, 32)), AuthTime: now} |
| + nonce := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{19}, 16)) |
| + init, err := store.Start(ctx, local, "/", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := store.BindNonce(ctx, init, local, nonce, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + proof, err := store.Complete(ctx, init, local, linkstore.Home{Issuer: c.Issuer, Subject: "home-person", Nonce: nonce, AuthTime: now}, now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := store.Confirm(ctx, proof, local, now); err != nil { |
| t.Fatal(err) |
| } |
| } |
| + |
| func TestHomeExistingIdentityNoCreationAndStableActors(t *testing.T) { |
| mux, a, u := homeFixture(t) |
| homeLinked(t, a, u) |
| @@ -409,3 +440,145 @@ func TestHomeLocalPluginRecoveryRotatesThenReturnsToLink(t *testing.T) { |
| t.Fatal("recovery seeded deck") |
| } |
| } |
| + |
| +func TestHomeTransientReadFailureKeepsAllSessions(t *testing.T) { |
| + mux, a, u := homeFixture(t) |
| + homeLinked(t, a, u) |
| + cookie := homeSession(t, a, "home-person", connect.HomeMethod) |
| + _ = homeSession(t, a, "home-person", connect.HomeMethod) |
| + if err := a.db.G.Callback().Query().Before("gorm:query").Register("home_test:transient", func(tx *gorm.DB) { |
| + if tx.Statement.Table == "users" { |
| + tx.AddError(errors.New("synthetic transient read failure")) |
| + } |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := httptest.NewRequest("GET", homeTestOrigin+"/", nil) |
| + r.AddCookie(cookie) |
| + w := httptest.NewRecorder() |
| + mux.ServeHTTP(w, r) |
| + if w.Code != 500 { |
| + t.Fatal("storage read did not fail", w.Code) |
| + } |
| + var n int64 |
| + a.db.G.Table("sessions").Count(&n) |
| + if n != 2 { |
| + t.Fatal("transient read revoked sessions", n) |
| + } |
| + for _, c := range w.Result().Cookies() { |
| + if c.Name == cookie.Name { |
| + t.Fatal("transient read mutated session cookie") |
| + } |
| + } |
| +} |
| +func TestHomeFinishRemovedPersonIsAdmissionRefusal(t *testing.T) { |
| + for _, remove := range []string{"deleted", "denied"} { |
| + t.Run(remove, func(t *testing.T) { |
| + mux, a, u, key := homeFixtureKey(t) |
| + homeLinked(t, a, u) |
| + start := httptest.NewRecorder() |
| + mux.ServeHTTP(start, httptest.NewRequest("GET", homeTestOrigin+handoff.StartPath, nil)) |
| + target, _ := url.Parse(start.Header().Get("Location")) |
| + if start.Code != 303 { |
| + t.Fatal("start", start.Code) |
| + } |
| + now := time.Now().Unix() |
| + id, _ := assertion.RandomID() |
| + token, err := assertion.Sign(key, assertion.Claims{Version: 1, Issuer: a.cfg.Home.Issuer, Audience: homeTestOrigin, Subject: "home-person", KeyID: "test", ID: id, Nonce: target.Query().Get("nonce"), IssuedAt: now, ExpiresAt: now + 90, AuthTime: now}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + callbackReq := httptest.NewRequest("POST", homeTestOrigin+handoff.CallbackPath, strings.NewReader(url.Values{"assertion": {token}}.Encode())) |
| + callbackReq.Header.Set("Origin", a.cfg.Home.Issuer) |
| + callbackReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + callback := httptest.NewRecorder() |
| + mux.ServeHTTP(callback, callbackReq) |
| + if callback.Code != 303 { |
| + t.Fatal("callback", callback.Code) |
| + } |
| + if remove == "deleted" { |
| + if err := a.db.G.Delete(&u).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + a.allowed = allowlist{addrs: map[string]struct{}{}} |
| + } |
| + finishReq := httptest.NewRequest("GET", homeTestOrigin+handoff.FinishPath, nil) |
| + for _, response := range []*httptest.ResponseRecorder{start, callback} { |
| + for _, c := range response.Result().Cookies() { |
| + if c.MaxAge >= 0 { |
| + finishReq.AddCookie(c) |
| + } |
| + } |
| + } |
| + finish := httptest.NewRecorder() |
| + mux.ServeHTTP(finish, finishReq) |
| + if finish.Code != 403 { |
| + t.Fatal("admission became storage failure", finish.Code, finish.Body.String()) |
| + } |
| + for _, c := range finish.Result().Cookies() { |
| + if c.Name == a.homeSessions.CookieName() { |
| + t.Fatal("removed person received session") |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestHomeEditorPresentationAndExportUseExistingOwner(t *testing.T) { |
| + mux, a, u := homeFixture(t) |
| + homeLinked(t, a, u) |
| + a.seedSampleDeckFor(context.Background(), u) |
| + var deck Deck |
| + if err := a.db.G.Where("owner_id=?", u.ID).Take(&deck).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + home := homeSession(t, a, "home-person", connect.HomeMethod) |
| + local := homeSession(t, a, u.Email, "magiclink") |
| + request := func(method, path string, cookie *http.Cookie) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest(method, homeTestOrigin+path, nil) |
| + r.AddCookie(cookie) |
| + if method == "POST" { |
| + r.Header.Set("Origin", homeTestOrigin) |
| + } |
| + w := httptest.NewRecorder() |
| + mux.ServeHTTP(w, r) |
| + return w |
| + } |
| + base := "/d/" + deck.Sqid |
| + for _, path := range []string{base + "/edit", base + "/updates?since=0", "/agents"} { |
| + if w := request("GET", path, home); w.Code != 200 { |
| + t.Fatal("Home route", path, w.Code) |
| + } |
| + } |
| + present := request("POST", base+"/present", home) |
| + if present.Code != 303 { |
| + t.Fatal("present", present.Code) |
| + } |
| + if notes := request("GET", present.Header().Get("Location"), home); notes.Code != 200 { |
| + t.Fatal("Home presenter lost access", notes.Code) |
| + } |
| + export := request("POST", base+"/export", home) |
| + if export.Code != 303 { |
| + t.Fatal("export", export.Code) |
| + } |
| + status := export.Header().Get("Location") |
| + if page := request("GET", status, home); page.Code != 200 { |
| + t.Fatal("Home job status", page.Code, page.Body.String()) |
| + } |
| + if page := request("GET", status, local); page.Code != 200 { |
| + t.Fatal("local method lost same owner's job", page.Code) |
| + } |
| + deadline := time.Now().Add(30 * time.Second) |
| + for time.Now().Before(deadline) { |
| + pdf := request("GET", status+".pdf", home) |
| + if pdf.Code == 200 { |
| + if !bytes.HasPrefix(pdf.Body.Bytes(), []byte("%PDF-")) { |
| + t.Fatal("export not PDF") |
| + } |
| + return |
| + } |
| + time.Sleep(25 * time.Millisecond) |
| + } |
| + t.Fatal("Home export did not become readable PDF") |
| +} |
diff --git a/internal/slides/presentation.go b/internal/slides/presentation.go| index 6002043..8cf083a 100644 |
| --- a/internal/slides/presentation.go |
| +++ b/internal/slides/presentation.go |
| @@ -42,7 +42,6 @@ |
| package slides |
| import ( |
| - "amadan.net/oficina/home/connect" |
| "crypto/rand" |
| "crypto/sha256" |
| "crypto/subtle" |
| @@ -59,15 +58,14 @@ import ( |
| "strings" |
| "time" |
| - "github.com/go-chi/chi/v5" |
| - "github.com/skip2/go-qrcode" |
| - "gorm.io/gorm" |
| - |
| - "amadan.net/rastrillo/rastrillo" |
| - |
| + "amadan.net/oficina/home/connect" |
| "amadan.net/oficina/slides/internal/slides/deck" |
| "amadan.net/oficina/slides/internal/slides/present" |
| slidehtml "amadan.net/oficina/slides/internal/slides/render" |
| + "amadan.net/rastrillo/rastrillo" |
| + "github.com/go-chi/chi/v5" |
| + "github.com/skip2/go-qrcode" |
| + "gorm.io/gorm" |
| ) |
| // --- what a session is, and how long it lasts --- |
diff --git a/internal/slides/render.go b/internal/slides/render.go| index 16212ab..1ed4ee8 100644 |
| --- a/internal/slides/render.go |
| +++ b/internal/slides/render.go |
| @@ -1,16 +1,15 @@ |
| package slides |
| import ( |
| - "amadan.net/oficina/home/connect" |
| "bytes" |
| "embed" |
| "html/template" |
| "net/http" |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/slides/internal/slides/icons" |
| "amadan.net/rastrillo/rastrillo" |
| "amadan.net/rastrillo/rastrillo/ui" |
| - |
| - "amadan.net/oficina/slides/internal/slides/icons" |
| ) |
| //go:embed templates static |