package platform import ( "bytes" "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "encoding/json" "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" "net/http" "net/http/httptest" "os" "regexp" "strings" "testing" _ "github.com/jackc/pgx/v5/stdlib" ) func TestOnboardingAndSharing(t *testing.T) { dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") if dsn == "" { t.Skip("requires an isolated integration database") } db, err := sql.Open("pgx", dsn) if err != nil { t.Fatal(err) } defer db.Close() ctx := context.Background() var raw [12]byte rand.Read(raw[:]) project := "test-" + hex.EncodeToString(raw[:]) accounts := []string{} tokens := []string{} for range 3 { var id string err = db.QueryRowContext(ctx, "INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text", project).Scan(&id) if err != nil { t.Fatal(err) } accounts = append(accounts, id) var value [32]byte rand.Read(value[:]) token := base64.RawURLEncoding.EncodeToString(value[:]) hash := sha256.Sum256([]byte(token)) _, err = db.ExecContext(ctx, "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '1 hour')", base64.RawURLEncoding.EncodeToString(hash[:]), project, id) if err != nil { t.Fatal(err) } tokens = append(tokens, token) } defer func() { _, _ = db.ExecContext(ctx, "DELETE FROM workspaces WHERE project=$1", project) _, _ = db.ExecContext(ctx, "DELETE FROM project_accounts WHERE project=$1", project) _, _ = db.ExecContext(ctx, "DELETE FROM email_outbox WHERE project=$1", project) }() callbacks := map[string]string{project: "https://app.example.com/auth/callback"} mux := http.NewServeMux() authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{}, nil) if err != nil { t.Fatal(err) } mux.Handle("/", authHandler) Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{ project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}}, }) call := func(method, path, token string, body any, status int, versions ...string) response { t.Helper() encoded, err := json.Marshal(body) if err != nil { t.Fatal(err) } r := httptest.NewRequest(method, path, bytes.NewReader(encoded)) if token != "" { r.Header.Set("Authorization", "Bearer "+token) } if len(versions) > 0 { r.Header.Set("If-Match", versions[0]) } w := httptest.NewRecorder() mux.ServeHTTP(w, r) if w.Code != status { t.Fatalf("%s %s got %d %s want %d", method, path, w.Code, w.Body.String(), status) } out := response{} if w.Body.Len() > 0 && w.Code != 302 { if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { t.Fatal(err) } } return out } base := "/projects/" + project call("GET", base+"/workspaces", "", nil, 401) call("POST", base+"/onboarding", tokens[0], response{"answers": response{}}, 400) first := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 201) workspace := first["result"].(map[string]any)["workspace_id"].(string) retry := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 200) if retry["result"].(map[string]any)["workspace_id"] != workspace { t.Fatal("onboarding created a second workspace") } workspacePath := base + "/workspaces/" + workspace share := call("POST", workspacePath+"/shares", tokens[0], response{"role": "viewer", "max_uses": 1}, 201) token := share["token"].(string) call("GET", "/s/"+token, "", nil, 302) clicks := call("GET", workspacePath+"/shares/"+share["id"].(string)+"/clicks", tokens[0], nil, 200) if len(clicks["items"].([]any)) != 1 { t.Fatal("click event API did not return the redirect") } call("GET", workspacePath+"/shares/"+share["id"].(string)+"/clicks", tokens[2], nil, 404) call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) call("POST", base+"/shares/redeem", tokens[2], response{"token": token}, 410) call("PATCH", workspacePath, tokens[1], response{"name": "Forbidden"}, 403) call("GET", workspacePath, tokens[2], nil, 404) call("PATCH", workspacePath+"/members/"+accounts[0], tokens[0], response{"role": "viewer"}, 409) call("DELETE", workspacePath+"/shares/"+share["id"].(string), tokens[0], nil, 204) call("GET", "/s/"+token, "", nil, 404) var material string err = db.QueryRowContext(ctx, "INSERT INTO materials(project,workspace_id,kind,title,created_by) VALUES($1,$2,'article','Shared',$3) RETURNING id::text", project, workspace, accounts[0]).Scan(&material) if err != nil { t.Fatal(err) } contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201) call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200) call("GET", workspacePath, tokens[2], nil, 404) articleContent := response{"schema_version": 1, "article": response{"sections": []any{response{ "id": "chapter-1", "title": "Chapter", "description": "Chapter summary", "paragraphs": []any{response{"id": "p1", "text": "Paragraph", "description": "Paragraph summary"}}, }}}} created := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Article", "content": articleContent}, 201) articleID := created["id"].(string) text := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "text", "title": "Alpha", "content": response{"text": "Original"}}, 201) textPath := workspacePath + "/materials/" + text["id"].(string) call("GET", textPath, tokens[2], nil, 404) call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) call("GET", textPath, tokens[2], nil, 200) changed := response{"title": "Beta", "content": response{"text": "Changed"}} call("PATCH", textPath, tokens[2], changed, 403, `"1"`) call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "editor"}, 204) call("PATCH", textPath, tokens[2], changed, 428) call("PATCH", textPath, tokens[2], changed, 204, `"1"`) call("PATCH", textPath, tokens[0], changed, 412, `"1"`) call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Invalid", "content": response{"markdown": "raw markdown"}}, 400) tag := call("POST", workspacePath+"/tags", tokens[0], response{"name": "Research", "color": "#123456"}, 201) call("GET", workspacePath+"/tags", tokens[2], nil, 404) call("POST", textPath+"/tags", tokens[2], response{"tags": []string{tag["id"].(string)}}, 204, `"2"`) call("POST", textPath+"/open", tokens[2], nil, 204) filtered := call("GET", workspacePath+"/materials?sort=opened&tag="+tag["id"].(string), tokens[2], nil, 200) if len(filtered["items"].([]any)) != 1 { t.Fatal("tag-filtered material list leaked inaccessible materials") } board := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Board", "content": response{}}, 201) boardPath := workspacePath + "/materials/" + board["id"].(string) canvasPath := boardPath + "/canvas" empty := call("GET", canvasPath, tokens[0], nil, 200) if empty["version"] != float64(1) { t.Fatal("old board not initialized at version 1") } snapshot := response{"document": json.RawMessage(canvasFixture)} call("PATCH", canvasPath, "", snapshot, 401, `"1"`) call("PATCH", canvasPath, tokens[1], snapshot, 403, `"1"`) call("PATCH", canvasPath, tokens[2], snapshot, 404, `"1"`) call("PATCH", canvasPath, tokens[0], snapshot, 428) call("PATCH", canvasPath, tokens[0], snapshot, 200, `"1"`) call("PATCH", canvasPath, tokens[0], snapshot, 412, `"1"`) saved := call("GET", canvasPath, tokens[0], nil, 200) expected, _ := normalizeCanvas(json.RawMessage(canvasFixture)) expectedJSON, _ := json.Marshal(json.RawMessage(expected)) actualJSON, _ := json.Marshal(saved["document"]) if !bytes.Equal(expectedJSON, actualJSON) { t.Fatal("snapshot failed round trip") } call("PATCH", canvasPath, tokens[0], response{"document": response{}}, 400, `"2"`) call("PATCH", boardPath, tokens[0], response{"title": "Renamed", "slug": "my-canvas", "content": response{}}, 204, `"1"`) if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(2) { t.Fatal("rename changed canvas version") } slugPath := workspacePath + "/materials/my-canvas" if call("GET", slugPath, tokens[0], nil, 200)["title"] != "Renamed" { t.Fatal("slug lookup failed") } // A PNG at the local upload limit must survive JSON and database storage. var imageDoc map[string]any json.Unmarshal([]byte(canvasFixture), &imageDoc) imageBytes := make([]byte, 2<<20) copy(imageBytes, []byte{137, 80, 78, 71, 13, 10, 26, 10}) data := imageDoc["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any) data["kind"] = "image" data["src"] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes) call("PATCH", canvasPath, tokens[0], response{"document": imageDoc}, 200, `"2"`) restored := call("GET", canvasPath, tokens[0], nil, 200) if restored["document"].(map[string]any)["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any)["src"] != data["src"] { t.Fatal("large image changed") } call("PATCH", canvasPath, tokens[0], response{"document": strings.Repeat("x", canvasLimit)}, 413, `"3"`) if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(3) { t.Fatal("rejected snapshot changed version") } // Restore the material version expected by the pre-existing layout checks below. // Exercise two independent HTTP transactions against the same expected version. encodedSnapshot, _ := json.Marshal(snapshot) statuses := make(chan int, 2) start := make(chan struct{}) for range 2 { go func() { <-start request := httptest.NewRequest("PATCH", canvasPath, bytes.NewReader(encodedSnapshot)) request.Header.Set("Authorization", "Bearer "+tokens[0]) request.Header.Set("If-Match", `"3"`) recorder := httptest.NewRecorder() mux.ServeHTTP(recorder, request) statuses <- recorder.Code }() } close(start) one, two := <-statuses, <-statuses if !((one == 200 && two == 412) || (one == 412 && two == 200)) { t.Fatalf("concurrent writes: %d %d", one, two) } if call("GET", boardPath, tokens[0], nil, 200)["title"] != "Renamed" { t.Fatal("canvas save changed title") } call("PATCH", workspacePath, tokens[0], response{"name": "Team", "slug": "research-team"}, 204) call("GET", base+"/workspaces/research-team/materials/my-canvas/canvas", tokens[0], nil, 200) call("PATCH", workspacePath, tokens[1], response{"name": "Team", "slug": "stolen"}, 403) call("POST", base+"/workspaces", tokens[0], response{"name": "Duplicate", "slug": "research-team"}, 409) call("PATCH", workspacePath, tokens[0], response{"name": "Renamed Team"}, 204) if call("GET", workspacePath, tokens[0], nil, 200)["slug"] != "research-team" { t.Fatal("rename changed slug") } layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"2"`) call("GET", boardPath+"/layouts", tokens[1], nil, 200) call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) hidden := call("GET", boardPath+"/layouts", tokens[2], nil, 200) if len(hidden["items"].([]any)) != 0 { t.Fatal("board share disclosed private child material") } call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 412, `"1"`) call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 204, `"3"`) call("DELETE", textPath+"/grants/"+accounts[2], tokens[0], nil, 204) call("GET", textPath, tokens[2], nil, 404) call("DELETE", workspacePath+"/tags/"+tag["id"].(string), tokens[0], nil, 204) call("PATCH", textPath, tokens[0], changed, 412, `"3"`) imageContent := response{"image": response{"url": "https://assets.example.com/image.png", "alt": "Photo"}} call("POST", workspacePath+"/materials", tokens[0], response{"kind": "image", "title": "Photo", "content": imageContent}, 201) call("POST", workspacePath+"/materials", tokens[0], response{"kind": "card", "title": "Mixed card", "content": response{"parts": []any{ response{"kind": "text", "text": "Caption"}, response{"kind": "article", "article": articleContent["article"]}, response{"kind": "image", "image": imageContent["image"]}, }}}, 201) for _, sort := range []string{"modified", "created", "alphabetical", "opened", "tag"} { call("GET", workspacePath+"/materials?sort="+sort+"&limit=2", tokens[0], nil, 200) } images := call("GET", workspacePath+"/materials?kind=image&q=Photo", tokens[0], nil, 200) if len(images["items"].([]any)) != 1 { t.Fatal("kind/title filter returned incorrect materials") } email := "fixture@example.invalid" password := "a-long-fixture-password" call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202) call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 401) var body string if err := db.QueryRowContext(ctx, "SELECT body FROM email_outbox WHERE project=$1 AND recipient=$2 ORDER BY created_at DESC LIMIT 1", project, email).Scan(&body); err != nil { t.Fatal(err) } match := regexp.MustCompile(`verify_email=([A-Za-z0-9_-]{43})`).FindStringSubmatch(body) if len(match) != 2 { t.Fatal("missing email verification link") } verified := call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 201) call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 400) call("GET", base+"/profile", verified["access_token"].(string), nil, 200) call("POST", base+"/auth/login", "", response{"email": email, "password": "wrong-long-password"}, 401) loggedIn := call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 200) call("POST", base+"/auth/logout", loggedIn["access_token"].(string), nil, 204) call("GET", base+"/profile", loggedIn["access_token"].(string), nil, 401) var count int if err := db.QueryRowContext(ctx, "SELECT count(*) FROM material_grants WHERE project=$1 AND account_id=$2 AND material_id=$3", project, accounts[2], material).Scan(&count); err != nil || count != 1 { t.Fatalf("material grant missing %v", err) } if err := db.QueryRowContext(ctx, "SELECT count(*) FROM link_clicks WHERE project=$1", project).Scan(&count); err != nil || count != 1 { t.Fatalf("click accounting incorrect %v", err) } }