317 lines
16 KiB
Go
317 lines
16 KiB
Go
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"
|
|
"strconv"
|
|
"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")
|
|
}
|
|
|
|
// Frames round-trip through PostgreSQL with all label modes and explicit memberships.
|
|
for index, mode := range []string{"hidden", "fixed", "scaled"} {
|
|
version := strconv.Itoa(4 + index)
|
|
raw := strings.Replace(frameFixture, `"labelMode":"fixed"`, `"labelMode":"`+mode+`"`, 1)
|
|
call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(raw)}, 200, `"`+version+`"`)
|
|
persisted := call("GET", canvasPath, tokens[0], nil, 200)
|
|
normalized, _ := normalizeCanvas(json.RawMessage(raw))
|
|
want, _ := json.Marshal(json.RawMessage(normalized))
|
|
got, _ := json.Marshal(persisted["document"])
|
|
if !bytes.Equal(want, got) {
|
|
t.Fatal("frame snapshot not preserved")
|
|
}
|
|
}
|
|
invalidFrame := strings.Replace(frameFixture, `"frameId":"frame"`, `"frameId":"missing"`, 1)
|
|
rejected := call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(invalidFrame)}, 400, `"7"`)
|
|
if rejected["error"] != "invalid_canvas" {
|
|
t.Fatal("unexpected frame error")
|
|
}
|
|
if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(7) {
|
|
t.Fatal("invalid frame changed saved version")
|
|
}
|
|
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)
|
|
}
|
|
}
|