Files
pad/internal/server/mobile_contract_test.go
xarmian 696b477b12 test(server): the mobile shells' unversioned contracts fail CI when they move (TASK-2053) (#1294)
test(server): the mobile shells' unversioned contracts fail CI when they move (TASK-2053)

The native shells talk to this server over string contracts nothing protects:
route paths, cookie names, JSON keys, a header shape. None sits behind the MCP
tool-surface version or any other gate, and the min-server-version warning the
app shows covers the opposite direction — it protects a NEW app against an OLD
server and says nothing when the server moves forward under a shipped build. A
shipped build cannot be patched on our schedule, so a renamed key is a silent
break for every installed copy until its owner updates.

One file, one table, six contracts, each naming the shell surface that breaks:

  - Both session cookie spellings. __Host-pad_session is the one that matters —
    every deployment a phone talks to is TLS — and it appeared in NO Go test
    assertion, only in a config-test comment. The insecure spelling is in
    nineteen test files as a helper building a request, which is a mention and
    not a claim about the name.
  - The six auth routes, asserted by WALKING the route table rather than by
    firing requests: a request-based check answers "did something handle this",
    which a catch-all or a redirect satisfies while the route itself is gone.
  - The /auth/session keys the app branches on before showing any UI, version
    included. A rename there reads on a phone as a blank screen or a login form
    on an instance that needs setup, not as an error.
  - The full two-step 2FA sign-in. requires_2fa, challenge_token and
    recovery_code were live request/response keys appearing in ZERO test files;
    the test drives login → login-verify with a real recovery code, so a rename
    on either side fails here.
  - Content-Disposition carrying a filename on BOTH dispositions (BUG-2910).
    The existing download tests assert the inline;/attachment; prefix and say
    nothing about the parameter after it, so the filename could be dropped from
    either branch with the suite green.

Scope is stated in the file rather than left for a reader to re-derive.
/auth/apple/native is a pad-cloud route; the app-scheme redirect allowlist does
not exist in this repo (greps for the scheme forms and for app_scheme/appScheme
return nothing in Go, TypeScript or Svelte); and the OAuth error codes this repo
emits belong to Dynamic Client Registration for MCP clients, not to mobile
sign-in — pinning them here would look like mobile coverage while protecting a
different client entirely.

Verified by an eight-mutant matrix: renaming either cookie name, the
login-verify route, requires_2fa, challenge_token or recovery_code, dropping
version from the session payload, or dropping the filename from
Content-Disposition each fails a named test. Every mutant is a change someone
could plausibly make in an afternoon, and every one of them was silent before.

Two of my own assumptions were caught by running it rather than by reading:
chi's router is built lazily, so walking it on a server that has served nothing
panicked; and the "downloaded document" case used a .txt, which is on the read
path's inline-safe allowlist and came back inline — the case would have tested
the same branch twice under a name claiming otherwise. It uses a real zip now.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-08 15:49:05 -04:00

264 lines
10 KiB
Go

package server
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/go-chi/chi/v5"
)
// TASK-2053. The native shells in ~/Dev/pad-mobile talk to this server over a
// handful of UNVERSIONED string contracts — route paths, cookie names, JSON
// keys, header shapes. None of them is behind the MCP tool-surface version or
// any other compatibility gate, and the min-server-version warning the app
// shows covers the opposite direction: it protects a NEW app against an OLD
// server, and says nothing when the server moves forward under a shipped app.
//
// A shipped mobile build cannot be patched on our schedule. Renaming a key here
// is a silent break for every installed copy until its owner updates, so these
// assertions exist to make that break LOUD — at CI time, in this repo, before
// it reaches a phone.
//
// HOW TO USE A FAILURE HERE. This file is not a correctness test and a red line
// does not mean the server is wrong. It means a contract a shipped client
// depends on has moved. The change may well be right; what it needs is the
// other half — a pad-mobile change, and a deliberate decision about clients
// already in the field. Update the constant here in the SAME unit that makes
// the server change, and say on the item's trail which shell was checked.
//
// One file, one table, per contract: the list is meant to be read as an
// inventory, and a new mobile-facing contract should be added here when it is
// added to the server.
//
// SCOPE, stated because a reader should not have to re-derive where the search
// stopped. Covered here: everything mobile depends on that this repo SERVES.
// Deliberately absent, each looked for rather than assumed:
//
// - `/auth/apple/native` is a pad-cloud route (its main.go registers it);
// that half of TASK-2053 belongs to that repo's suite.
// - The app-scheme redirect allowlist does not exist in this repo — greps for
// the scheme forms and for `app_scheme`/`appScheme` return nothing in Go,
// TypeScript or Svelte. It lives with the native OAuth callback, in
// pad-cloud.
// - The OAuth error codes this repo emits (`invalid_client_metadata` and
// friends, handlers_oauth.go) belong to Dynamic Client Registration for MCP
// clients, not to the mobile sign-in flow. Pinning them here would look
// like mobile coverage while protecting a different client entirely.
// mobileContractCookieNames pins both spellings of the session cookie.
//
// The shells read the cookie by name after a web-view login, so the value is a
// literal in their source. `__Host-pad_session` is the one that matters in
// practice — every deployment a phone talks to is TLS — and it appeared in NO
// Go test assertion before this file: only in a config-test comment. The
// insecure spelling is mentioned in nineteen test files, but as a helper
// building a request rather than as a claim about the name.
func TestMobileContract_SessionCookieNames(t *testing.T) {
if got := sessionCookieName(false); got != "pad_session" {
t.Errorf("sessionCookieName(false) = %q, want %q — pad-mobile reads this name literally", got, "pad_session")
}
if got := sessionCookieName(true); got != "__Host-pad_session" {
t.Errorf("sessionCookieName(true) = %q, want %q — this is the spelling every TLS deployment (and so every phone) sees",
got, "__Host-pad_session")
}
}
// TestMobileContract_AuthRoutesExist walks the real route table rather than
// firing requests: a request-based check answers "did something handle this",
// which a catch-all or a redirect can satisfy while the route itself is gone.
// Walking asserts the METHOD + PATH pair is registered.
func TestMobileContract_AuthRoutesExist(t *testing.T) {
srv := testServer(t)
// The router is built lazily on first request (ensureRouter), so a server
// that has served nothing has a nil one — walking it directly panics.
srv.ensureRouter()
registered := map[string]bool{}
err := chi.Walk(srv.router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
registered[method+" "+strings.TrimSuffix(route, "/")] = true
return nil
})
if err != nil {
t.Fatalf("walk routes: %v", err)
}
// Each entry names the shell surface that breaks if the route moves.
for _, tc := range []struct{ route, usedBy string }{
{"GET /api/v1/auth/session", "launch: setup_required / auth_method probe before showing any UI"},
{"POST /api/v1/auth/register", "sign-up form"},
{"POST /api/v1/auth/login", "email+password sign-in"},
{"POST /api/v1/auth/2fa/login-verify", "the second step of a 2FA sign-in"},
{"POST /api/v1/auth/logout", "sign-out"},
{"GET /api/v1/auth/me", "profile load after sign-in"},
} {
if !registered[tc.route] {
t.Errorf("route %q is not registered — pad-mobile depends on it for %s", tc.route, tc.usedBy)
}
}
}
// TestMobileContract_SessionPayloadKeys pins the keys the shells branch on
// before showing any UI. A renamed key here reads on a phone as a blank screen
// or a login form on an instance that needs setup, not as an error.
func TestMobileContract_SessionPayloadKeys(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/auth/session", nil)
if rr.Code != http.StatusOK {
t.Fatalf("GET /auth/session = %d, want 200: %s", rr.Code, rr.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode session payload: %v", err)
}
for _, key := range []string{"authenticated", "setup_required", "auth_method", "version"} {
if _, ok := payload[key]; !ok {
t.Errorf("session payload has no %q key — pad-mobile branches on it at launch (keys present: %v)",
key, sortedKeys(payload))
}
}
}
// TestMobileContract_TwoFactorLoginRoundTrip is the contract with the most
// moving parts and the least coverage: before this file, `requires_2fa`,
// `challenge_token` and `recovery_code` appeared in ZERO test files while being
// live response and request keys.
//
// It drives the whole two-step sign-in the shells implement — login returns a
// challenge, login-verify redeems it with a recovery code — so a rename on
// either side fails here rather than on a phone.
func TestMobileContract_TwoFactorLoginRoundTrip(t *testing.T) {
srv := testServer(t)
const email = "mobile-2fa@example.com"
const password = "correct-horse-battery-staple"
const recoveryCode = "PAD-MOBILE-RECOVERY"
user, err := srv.store.CreateUser(models.UserCreate{
Email: email, Name: "Mobile Tester", Password: password, Role: "member",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
// Recovery codes are stored as newline-joined sha256 hex digests
// (store.ConsumeRecoveryCode hashes the input the same way).
sum := sha256.Sum256([]byte(recoveryCode))
if err := srv.store.SetTOTPSecret(user.ID, "JBSWY3DPEHPK3PXP"); err != nil {
t.Fatalf("SetTOTPSecret: %v", err)
}
if err := srv.store.EnableTOTP(user.ID, "JBSWY3DPEHPK3PXP", hex.EncodeToString(sum[:])); err != nil {
t.Fatalf("EnableTOTP: %v", err)
}
rr := doRequest(srv, "POST", "/api/v1/auth/login", map[string]any{
"email": email, "password": password,
})
if rr.Code != http.StatusOK {
t.Fatalf("login = %d, want 200: %s", rr.Code, rr.Body.String())
}
var login map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &login); err != nil {
t.Fatalf("decode login: %v", err)
}
if login["requires_2fa"] != true {
t.Errorf("login response has no `requires_2fa: true` — the shells use it to decide whether to show "+
"the code screen (keys: %v)", sortedKeys(login))
}
challenge, _ := login["challenge_token"].(string)
if challenge == "" {
t.Fatalf("login response carries no non-empty `challenge_token` — the second step cannot be made "+
"(keys: %v)", sortedKeys(login))
}
// The shells send exactly these two request keys.
rr = doRequest(srv, "POST", "/api/v1/auth/2fa/login-verify", map[string]any{
"challenge_token": challenge,
"recovery_code": recoveryCode,
})
if rr.Code != http.StatusOK {
t.Fatalf("login-verify with challenge_token + recovery_code = %d, want 200: %s", rr.Code, rr.Body.String())
}
}
// TestMobileContract_AttachmentDispositionCarriesAFilename pins the header form
// on BOTH dispositions (BUG-2910). The shells parse the filename out of
// Content-Disposition to name a downloaded or shared file; the existing
// download tests assert the `inline;` / `attachment;` PREFIX and say nothing
// about the parameter that follows it, so the filename could be dropped from
// either branch with the suite green.
func TestMobileContract_AttachmentDispositionCarriesAFilename(t *testing.T) {
srv, slug := testServerWithAttachments(t)
for _, tc := range []struct {
name string
filename string
body []byte
disposition string
}{
{"inline-served image", "shot.png", realPNG(), "inline"},
// A ZIP, not a .txt: text/plain is on the read path's inline-safe
// allowlist, so a text file comes back `inline` too and the case would
// have tested the same branch twice under a name claiming otherwise.
{"downloaded archive", "bundle.zip", realZIP(t), "attachment"},
} {
t.Run(tc.name, func(t *testing.T) {
rr := doMultipartUpload(srv, slug, tc.filename, tc.body)
if rr.Code != http.StatusCreated {
t.Fatalf("upload: %d %s", rr.Code, rr.Body.String())
}
var up struct {
ID string `json:"id"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &up); err != nil {
t.Fatalf("decode upload: %v", err)
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/attachments/"+up.ID, nil)
if rr.Code != http.StatusOK {
t.Fatalf("download: %d %s", rr.Code, rr.Body.String())
}
got := rr.Header().Get("Content-Disposition")
want := tc.disposition + `; filename="` + tc.filename + `"`
if got != want {
t.Errorf("Content-Disposition = %q, want %q — pad-mobile parses the filename out of this header",
got, want)
}
})
}
}
// realZIP builds a valid, minimal zip archive so the upload sniffs as
// application/zip — a category the read path serves as an attachment.
func realZIP(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("readme.txt")
if err != nil {
t.Fatalf("zip create: %v", err)
}
if _, err := w.Write([]byte("in a zip\n")); err != nil {
t.Fatalf("zip write: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
return buf.Bytes()
}
func sortedKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}