Files
pad/internal/server/task3452_tutorials_test.go
T
xarmian 4fdc6ed625 feat: in-app tutorials on Pad Cloud, links to getpad.dev/learn elsewhere (TASK-3452) (#1839)
Design and rulings on TASK-3452 (lead, day 89).

- Placement: a first-time card on /console's empty state (Start a new
  project) and in the workspace launchpad's step 2 (Set up your workspace
  with /pad onboard); each vanishes with its screen. The stable way back is
  a Tutorials entry in the user menu's Resources block, right after Docs.
- Dismissal: users.ui_dismissals, a JSON set of keys behind an allow-list
  (models.UIDismissalKeys), read-modify-written under the users row lock,
  so a dismissal holds on every device. GET/PUT /me/ui-dismissals, web only.
- Pad Cloud: the server fetches https://www.getpad.dev/learn/catalog.json
  (a constant; 1h cache, last good copy kept, 5 min back-off) and serves it
  same-origin at /tutorials, with posters proxied at /tutorials/posters/
  {slug} from www.getpad.dev only. The page CSP gains frame-src for
  youtube-nocookie, Cloud only. /console/tutorials hosts the click-to-load
  player and chapters; transcripts link to getpad.dev.
- Self-hosted: both routes 404, no outbound request, no CSP change; every
  surface links to getpad.dev/learn.

Migration 127 / pg 101, after #1837's 126 / 100 (rides rc.7). No CLI or MCP surface.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 22:23:29 -04:00

272 lines
11 KiB
Go

package server
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-3452: in-app tutorials. Pad Cloud's server fetches the getpad.dev
// catalog and posters and serves them same-origin; a self-hosted server never
// makes an outbound call and links out instead.
type tutorialsRoundTrip func(*http.Request) (*http.Response, error)
func (f tutorialsRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func tutorialsResponse(status int, ctype, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": {ctype}},
Body: io.NopCloser(strings.NewReader(body)),
}
}
const tutorialsTestCatalog = `{
"version": 1,
"paths": [{"id": "getting-started", "title": "Getting started", "blurb": "b"}],
"tutorials": [
{"slug": "new-project", "title": "Start a new project with Pad", "path": "getting-started", "youtube_id": null, "seconds": 149,
"poster": "https://www.getpad.dev/learn/new-project.jpg", "chapters": [{"t": 12.9, "title": "Create your account"}],
"url": "https://www.getpad.dev/learn/new-project", "next": "onboard"},
{"slug": "elsewhere", "title": "Poster off getpad.dev", "path": "getting-started", "youtube_id": null, "seconds": 10,
"poster": "https://evil.example/x.jpg", "chapters": [], "url": "https://www.getpad.dev/learn", "next": null},
{"slug": "first-task", "title": "Work through your first task", "path": "getting-started", "youtube_id": null, "seconds": null,
"poster": null, "chapters": [], "url": "https://www.getpad.dev/learn", "next": null}
],
"demos": []
}`
// cloudTutorialsServer is a cloud-mode server whose outbound client answers
// from the fakes and counts what it was asked for.
func cloudTutorialsServer(t *testing.T, catalogStatus int) (*Server, *[]string) {
t.Helper()
srv := testServer(t)
srv.SetCloudMode("secret-for-tutorials")
var asked []string
srv.tutorialsHTTP = &http.Client{Timeout: time.Second, Transport: tutorialsRoundTrip(func(r *http.Request) (*http.Response, error) {
asked = append(asked, r.URL.String())
switch r.URL.String() {
case tutorialsCatalogURL:
return tutorialsResponse(catalogStatus, "application/json", tutorialsTestCatalog), nil
case "https://www.getpad.dev/learn/new-project.jpg":
return tutorialsResponse(200, "image/jpeg", "JPEGBYTES"), nil
}
return tutorialsResponse(404, "text/plain", "no"), nil
})}
return srv, &asked
}
func TestTASK3452_SelfHostedNeverFetchesAndAddsNoFrameSrc(t *testing.T) {
srv := testServer(t)
var calls atomic.Int32
srv.tutorialsHTTP = &http.Client{Transport: tutorialsRoundTrip(func(r *http.Request) (*http.Response, error) {
calls.Add(1)
return nil, io.EOF
})}
for _, path := range []string{"/api/v1/tutorials", "/api/v1/tutorials/posters/new-project"} {
if rr := doRequest(srv, "GET", path, nil); rr.Code != http.StatusNotFound {
t.Errorf("self-hosted %s: %d %s, want 404", path, rr.Code, rr.Body.String())
}
}
if n := calls.Load(); n != 0 {
t.Errorf("a self-hosted server made %d outbound tutorial requests, want 0", n)
}
srv.SetWebUI(webFS(map[string]string{"index.html": "<!doctype html><html><head></head></html>"}))
rr := doRequest(srv, "GET", "/", nil)
if csp := rr.Header().Get("Content-Security-Policy"); strings.Contains(csp, "frame-src") {
t.Errorf("self-hosted page CSP carries frame-src: %s", csp)
}
}
func TestTASK3452_CloudServesTheCatalogSameOrigin(t *testing.T) {
srv, asked := cloudTutorialsServer(t, 200)
rr := doRequest(srv, "GET", "/api/v1/tutorials", nil)
if rr.Code != http.StatusOK {
t.Fatalf("GET /tutorials: %d %s", rr.Code, rr.Body.String())
}
var body struct {
Available bool `json:"available"`
Tutorials []tutorialEntry `json:"tutorials"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.Available || len(body.Tutorials) != 3 {
t.Fatalf("available=%v tutorials=%d, want true/3", body.Available, len(body.Tutorials))
}
if p := body.Tutorials[0].Poster; p == nil || *p != "/api/v1/tutorials/posters/new-project" {
t.Errorf("poster not rewritten same-origin: %v", p)
}
if body.Tutorials[2].Poster != nil {
t.Errorf("a tutorial with no poster gained one: %v", *body.Tutorials[2].Poster)
}
// Cached: a second read fetches nothing.
before := len(*asked)
doRequest(srv, "GET", "/api/v1/tutorials", nil)
if len(*asked) != before {
t.Errorf("a second read refetched the catalog: %v", *asked)
}
rr = doRequest(srv, "GET", "/api/v1/tutorials/posters/new-project", nil)
if rr.Code != http.StatusOK || rr.Body.String() != "JPEGBYTES" || rr.Header().Get("Content-Type") != "image/jpeg" {
t.Errorf("poster proxy: %d %q %q", rr.Code, rr.Header().Get("Content-Type"), rr.Body.String())
}
// A poster the catalog places off getpad.dev is never fetched.
rr = doRequest(srv, "GET", "/api/v1/tutorials/posters/elsewhere", nil)
if rr.Code == http.StatusOK {
t.Errorf("a poster off getpad.dev was served: %d", rr.Code)
}
for _, u := range *asked {
if strings.Contains(u, "evil.example") {
t.Errorf("the server fetched a poster off getpad.dev: %s", u)
}
}
srv.SetWebUI(webFS(map[string]string{"index.html": "<!doctype html><html><head></head></html>"}))
page := doRequest(srv, "GET", "/", nil)
if csp := page.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "frame-src https://www.youtube-nocookie.com;") {
t.Errorf("cloud page CSP lacks the youtube-nocookie frame-src: %s", csp)
}
}
func TestTASK3452_CloudCatalogUnavailableFallsBackToLinks(t *testing.T) {
srv, _ := cloudTutorialsServer(t, http.StatusServiceUnavailable)
rr := doRequest(srv, "GET", "/api/v1/tutorials", nil)
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"available":false`) {
t.Errorf("unavailable catalog: %d %s, want 200 available:false", rr.Code, rr.Body.String())
}
}
func TestTASK3452_UIDismissals(t *testing.T) {
bothBackends(t, func(t *testing.T, srv *Server) {
u, err := srv.store.CreateUser(models.UserCreate{Email: "dismiss-3452@example.com", Name: "D", Username: "dismiss-3452", Password: "pw-test-12345"})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token, err := srv.store.CreateSession(u.ID, "go-test", "192.0.2.1", "go-test", 24*time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
list := func() string {
rr := doRequestWithCookie(srv, "GET", "/api/v1/me/ui-dismissals", nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("list: %d %s", rr.Code, rr.Body.String())
}
return strings.TrimSpace(rr.Body.String())
}
if got := list(); got != `{"dismissed":[]}` {
t.Errorf("fresh user: %s", got)
}
for i := 0; i < 2; i++ { // idempotent
if rr := doRequestWithCookie(srv, "PUT", "/api/v1/me/ui-dismissals/tutorials.launchpad", nil, token); rr.Code != http.StatusOK {
t.Fatalf("dismiss: %d %s", rr.Code, rr.Body.String())
}
}
doRequestWithCookie(srv, "PUT", "/api/v1/me/ui-dismissals/tutorials.console", nil, token)
if got := list(); got != `{"dismissed":["tutorials.console","tutorials.launchpad"]}` {
t.Errorf("after two dismissals: %s", got)
}
if rr := doRequestWithCookie(srv, "PUT", "/api/v1/me/ui-dismissals/anything-else", nil, token); rr.Code != http.StatusBadRequest {
t.Errorf("unknown key: %d, want 400", rr.Code)
}
if got := list(); strings.Contains(got, "anything-else") {
t.Errorf("an unknown key was stored: %s", got)
}
})
}
// codex round 1: an allowed getpad.dev poster URL that redirects elsewhere is
// not followed.
func TestTASK3452_PosterRedirectIsNotFollowed(t *testing.T) {
srv := testServer(t)
srv.SetCloudMode("secret-for-tutorials")
var asked []string
srv.tutorialsHTTP = &http.Client{Timeout: time.Second, Transport: tutorialsRoundTrip(func(r *http.Request) (*http.Response, error) {
asked = append(asked, r.URL.String())
switch r.URL.String() {
case tutorialsCatalogURL:
return tutorialsResponse(200, "application/json", tutorialsTestCatalog), nil
case "https://www.getpad.dev/learn/new-project.jpg":
resp := tutorialsResponse(http.StatusFound, "text/plain", "")
resp.Header.Set("Location", "http://169.254.169.254/latest/meta-data")
return resp, nil
}
return tutorialsResponse(200, "image/jpeg", "SHOULD-NOT-BE-FETCHED"), nil
})}
rr := doRequest(srv, "GET", "/api/v1/tutorials/posters/new-project", nil)
if rr.Code == http.StatusOK {
t.Errorf("a redirected poster was served: %q", rr.Body.String())
}
for _, u := range asked {
if strings.Contains(u, "169.254") {
t.Errorf("the server followed a poster redirect to %s", u)
}
}
}
// codex round 1: a stale catalog is served at once while a slow refresh runs;
// no request waits on the network when a copy is in hand.
func TestTASK3452_StaleCatalogServedWhileUpstreamHangs(t *testing.T) {
srv := testServer(t)
srv.SetCloudMode("secret-for-tutorials")
release := make(chan struct{})
defer close(release)
srv.tutorialsHTTP = &http.Client{Timeout: 10 * time.Second, Transport: tutorialsRoundTrip(func(r *http.Request) (*http.Response, error) {
<-release
return tutorialsResponse(503, "text/plain", ""), nil
})}
var cat tutorialCatalog
if err := json.Unmarshal([]byte(tutorialsTestCatalog), &cat); err != nil {
t.Fatal(err)
}
srv.tutorials.catalog = &cat
srv.tutorials.fetchedAt = time.Now().Add(-2 * tutorialsTTL)
start := time.Now()
rr := doRequest(srv, "GET", "/api/v1/tutorials", nil)
if took := time.Since(start); took > 2*time.Second {
t.Errorf("a request with a stale copy waited %s on the upstream", took)
}
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"available":true`) {
t.Errorf("stale catalog not served: %d %s", rr.Code, rr.Body.String())
}
}
// codex round 1: two dismissals racing from two devices both land, on both
// backends (SQLite serializes writers with BEGIN IMMEDIATE; Postgres takes
// the users row FOR NO KEY UPDATE).
func TestTASK3452_ConcurrentDismissalsBothLand(t *testing.T) {
bothBackends(t, func(t *testing.T, srv *Server) {
for round := 0; round < 5; round++ {
u, err := srv.store.CreateUser(models.UserCreate{Email: fmt.Sprintf("race-%d-3452@example.com", round), Name: "R", Username: fmt.Sprintf("race-%d-3452", round), Password: "pw-test-12345"})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
errs := make(chan error, 2)
for _, k := range models.UIDismissalKeys {
go func(k string) { _, err := srv.store.DismissUI(u.ID, k); errs <- err }(k)
}
for range models.UIDismissalKeys {
if err := <-errs; err != nil {
t.Fatalf("round %d: DismissUI: %v", round, err)
}
}
got, err := srv.store.ListUIDismissals(u.ID)
if err != nil {
t.Fatal(err)
}
if len(got) != len(models.UIDismissalKeys) {
t.Fatalf("round %d: %v, want both keys", round, got)
}
}
})
}