Test the native exchange across both sessions packages (review #44, nit 1)
native.go's one pre-move import builds rastrillo/native's sessions over the app's table, and nothing exercised that bridge: the exchange, a minted session's expiry and its revocation. Three tests now drive it through the router: approval, 202 before it, a wrong verifier refused, the minted cookie under the name the app reads, accepted by the app as the same person, spent after one use; an expired browser session can neither approve nor exchange, and a minted one expires; a browser signed out between approval and exchange is refused, and the app's /signout ends a minted session and leaves the browser's. Red under three mutations of native.go (an http origin, so another cookie name; already-expired mints; a row resolving to someone else): polish-audit/r3/scripts/migrate-sheets-native-red.txt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+245
−0
internal/sheets/native_exchange_test.go+245 −0
diff --git a/internal/sheets/native_exchange_test.go b/internal/sheets/native_exchange_test.go| new file mode 100644 |
| index 0000000..0083188 |
| --- /dev/null |
| +++ b/internal/sheets/native_exchange_test.go |
| @@ -0,0 +1,245 @@ |
| +package sheets |
| + |
| +import ( |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| +) |
| + |
| +// The native sign-in crosses two rastrillo module paths. The app's |
| +// sessions are amadan.net/rastrillo/rastrillo's; rastrillo/native's |
| +// server only takes github.com/carlosframework/rastrillo's, which |
| +// native.go builds over the same database (the one import |
| +// TestNoPreMoveRastrillo allows). These tests drive the whole exchange |
| +// through the router, so a change to either package's sessions table, |
| +// cookie name, token hash or time layout fails here rather than in the |
| +// Mac app: approval of a browser session, the exchange minting a new |
| +// one, that one being an ordinary session to the app, and expiry and |
| +// revocation holding on both sides. |
| + |
| +// nativeApp is signInWithOrigin's app with the native routes mounted, as |
| +// main mounts them, and the browser session's cookies. |
| +func nativeApp(t *testing.T) (*App, []*http.Cookie) { |
| + t.Helper() |
| + app, cookies := signInWithOrigin(t) |
| + if err := app.ConfigureNative(); err != nil { |
| + t.Fatalf("ConfigureNative: %v", err) |
| + } |
| + return app, cookies |
| +} |
| + |
| +func randomProof(t *testing.T) string { |
| + t.Helper() |
| + b := make([]byte, 32) |
| + if _, err := rand.Read(b); err != nil { |
| + t.Fatal(err) |
| + } |
| + return base64.RawURLEncoding.EncodeToString(b) |
| +} |
| + |
| +// nativeJSONPost is a native client's POST: JSON, from the app's origin. |
| +func nativeJSONPost(t *testing.T, app *App, path string, body any) *httptest.ResponseRecorder { |
| + t.Helper() |
| + data, err := json.Marshal(body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(data))) |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Origin", testOrigin) |
| + return do(t, app, req) |
| +} |
| + |
| +// nativeRequest starts a request and returns its id and verifier. |
| +func nativeRequest(t *testing.T, app *App) (id, verifier string) { |
| + t.Helper() |
| + verifier = randomProof(t) |
| + sum := sha256.Sum256([]byte(verifier)) |
| + rec := nativeJSONPost(t, app, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(sum[:])}) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("POST /native/request = %d: %s", rec.Code, rec.Body.String()) |
| + } |
| + var out struct{ ID string } |
| + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.ID == "" { |
| + t.Fatalf("POST /native/request answered %q", rec.Body.String()) |
| + } |
| + return out.ID, verifier |
| +} |
| + |
| +// nativeApprove is the signed-in browser pressing "Sign in to Sheets app". |
| +func nativeApprove(t *testing.T, app *App, cookies []*http.Cookie, id string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + req := httptest.NewRequest(http.MethodPost, "/native/authorize?id="+id, nil) |
| + req.Header.Set("Origin", testOrigin) |
| + return do(t, app, as(req, cookies)) |
| +} |
| + |
| +func nativeExchange(t *testing.T, app *App, id, verifier string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + return nativeJSONPost(t, app, "/native/exchange", map[string]string{"id": id, "verifier": verifier}) |
| +} |
| + |
| +// sessionCookie picks the session cookie out of cookies. |
| +func sessionCookie(t *testing.T, cookies []*http.Cookie) *http.Cookie { |
| + t.Helper() |
| + for _, c := range cookies { |
| + if strings.Contains(c.Name, "session") { |
| + return c |
| + } |
| + } |
| + t.Fatalf("no session cookie in %v", cookies) |
| + return nil |
| +} |
| + |
| +// listWith is GET /sheets with one session cookie: 200 signed in. |
| +func listWith(t *testing.T, app *App, c *http.Cookie) int { |
| + t.Helper() |
| + return do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), []*http.Cookie{c})).Code |
| +} |
| + |
| +// setSessionExpiry rewrites one session row's expiry, by its token. |
| +func setSessionExpiry(t *testing.T, app *App, token string, at time.Time) { |
| + t.Helper() |
| + res := app.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token_hash = ?`, |
| + at.UTC().Format(time.RFC3339), sessions.HashToken(token)) |
| + if res.Error != nil || res.RowsAffected != 1 { |
| + t.Fatalf("expire session: %v, %d rows", res.Error, res.RowsAffected) |
| + } |
| +} |
| + |
| +func TestNativeExchangeMintsASessionTheAppAccepts(t *testing.T) { |
| + app, cookies := nativeApp(t) |
| + browser := sessionCookie(t, cookies) |
| + create := postForm("/sheets", url.Values{"title": {"Native probe"}}) |
| + create.Header.Set("Origin", testOrigin) |
| + if rec := do(t, app, as(create, cookies)); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("creating a sheet = %d: %s", rec.Code, rec.Body.String()) |
| + } |
| + id, verifier := nativeRequest(t, app) |
| + |
| + if rec := nativeExchange(t, app, id, verifier); rec.Code != http.StatusAccepted { |
| + t.Fatalf("exchange before approval = %d, want 202", rec.Code) |
| + } |
| + if rec := nativeApprove(t, app, cookies, id); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "is signed in") { |
| + t.Fatalf("approval = %d: %s", rec.Code, rec.Body.String()) |
| + } |
| + if rec := nativeExchange(t, app, id, randomProof(t)); rec.Code != http.StatusNotFound { |
| + t.Fatalf("exchange with the wrong verifier = %d, want 404", rec.Code) |
| + } |
| + rec := nativeExchange(t, app, id, verifier) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("exchange = %d: %s", rec.Code, rec.Body.String()) |
| + } |
| + var minted struct{ Name, Value string } |
| + if err := json.Unmarshal(rec.Body.Bytes(), &minted); err != nil { |
| + t.Fatalf("exchange answered %q", rec.Body.String()) |
| + } |
| + // The pre-move package names the cookie the app reads, and mints a |
| + // new token rather than handing back the browser's. |
| + if minted.Name != browser.Name { |
| + t.Fatalf("exchange minted cookie %q; the app reads %q", minted.Name, browser.Name) |
| + } |
| + if minted.Value == "" || minted.Value == browser.Value { |
| + t.Fatal("exchange did not mint a session of its own") |
| + } |
| + native := &http.Cookie{Name: minted.Name, Value: minted.Value} |
| + if code := listWith(t, app, native); code != http.StatusOK { |
| + t.Fatalf("the app refuses the session native minted: GET /sheets = %d", code) |
| + } |
| + // And it is the same person: their sheet is in the list. |
| + list := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), []*http.Cookie{native})) |
| + if !strings.Contains(list.Body.String(), "Native probe") { |
| + t.Fatal("the minted session is somebody else: the browser's sheet is not in its list") |
| + } |
| + // Spent: the same id never exchanges twice. |
| + if rec := nativeExchange(t, app, id, verifier); rec.Code != http.StatusNotFound { |
| + t.Fatalf("second exchange = %d, want 404", rec.Code) |
| + } |
| +} |
| + |
| +func TestNativeSessionExpiryHolds(t *testing.T) { |
| + app, cookies := nativeApp(t) |
| + browser := sessionCookie(t, cookies) |
| + |
| + // A browser session that has expired cannot approve. |
| + id, _ := nativeRequest(t, app) |
| + setSessionExpiry(t, app, browser.Value, time.Now().Add(-time.Minute)) |
| + if rec := nativeApprove(t, app, cookies, id); rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "is signed in") { |
| + t.Fatal("an expired browser session approved a native sign-in") |
| + } |
| + |
| + // Approved while live, expired before the exchange: refused. |
| + setSessionExpiry(t, app, browser.Value, time.Now().Add(time.Hour)) |
| + id, verifier := nativeRequest(t, app) |
| + nativeApprove(t, app, cookies, id) |
| + setSessionExpiry(t, app, browser.Value, time.Now().Add(-time.Minute)) |
| + if rec := nativeExchange(t, app, id, verifier); rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("exchange of an expired approval = %d, want 401", rec.Code) |
| + } |
| + |
| + // A minted session expires like any other. |
| + setSessionExpiry(t, app, browser.Value, time.Now().Add(time.Hour)) |
| + id, verifier = nativeRequest(t, app) |
| + nativeApprove(t, app, cookies, id) |
| + rec := nativeExchange(t, app, id, verifier) |
| + var minted struct{ Name, Value string } |
| + if err := json.Unmarshal(rec.Body.Bytes(), &minted); err != nil { |
| + t.Fatalf("exchange = %d %q", rec.Code, rec.Body.String()) |
| + } |
| + native := &http.Cookie{Name: minted.Name, Value: minted.Value} |
| + if code := listWith(t, app, native); code != http.StatusOK { |
| + t.Fatalf("live minted session: GET /sheets = %d", code) |
| + } |
| + setSessionExpiry(t, app, minted.Value, time.Now().Add(-time.Minute)) |
| + if code := listWith(t, app, native); code == http.StatusOK { |
| + t.Fatal("the app still accepts a minted session past its expiry") |
| + } |
| +} |
| + |
| +func TestNativeSessionRevocationHolds(t *testing.T) { |
| + app, cookies := nativeApp(t) |
| + browser := sessionCookie(t, cookies) |
| + |
| + // Signing the browser out between approval and exchange refuses it. |
| + id, verifier := nativeRequest(t, app) |
| + nativeApprove(t, app, cookies, id) |
| + if res := app.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, sessions.HashToken(browser.Value)); res.Error != nil || res.RowsAffected != 1 { |
| + t.Fatalf("revoke browser session: %v", res.Error) |
| + } |
| + if rec := nativeExchange(t, app, id, verifier); rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("exchange after the browser signed out = %d, want 401", rec.Code) |
| + } |
| + |
| + // The app signing out a minted session (the app's own sessions |
| + // package, deleting a row the pre-move package wrote) ends it, and |
| + // leaves the browser's alone. |
| + app, cookies = nativeApp(t) |
| + browser = sessionCookie(t, cookies) |
| + id, verifier = nativeRequest(t, app) |
| + nativeApprove(t, app, cookies, id) |
| + var minted struct{ Name, Value string } |
| + if rec := nativeExchange(t, app, id, verifier); json.Unmarshal(rec.Body.Bytes(), &minted) != nil { |
| + t.Fatalf("exchange = %d %q", rec.Code, rec.Body.String()) |
| + } |
| + native := &http.Cookie{Name: minted.Name, Value: minted.Value} |
| + out := httptest.NewRequest(http.MethodPost, "/signout", nil) |
| + out.Header.Set("Origin", testOrigin) |
| + if rec := do(t, app, as(out, []*http.Cookie{native})); rec.Code >= 400 { |
| + t.Fatalf("POST /signout with the minted session = %d", rec.Code) |
| + } |
| + if code := listWith(t, app, native); code == http.StatusOK { |
| + t.Fatal("a signed-out minted session still reaches /sheets") |
| + } |
| + if code := listWith(t, app, browser); code != http.StatusOK { |
| + t.Fatalf("signing native out ended the browser's session too: GET /sheets = %d", code) |
| + } |
| +} |