mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Replace the frontend's hash-based routing with the History API and real `/` paths, so URLs read like native file paths: /#p-4e61c7d4/shared%2Fidea.md -> /p-4e61c7d4/shared/idea.md /#shared%2Fnotes.md -> /shared/notes.md (volume mode) /#join/<token> -> /join/<token> Client (app.js): - parseRoute() reads location.pathname; pushURL/syncURL push native paths (segments percent-encoded, "/" kept literal); a popstate handler restores back/forward. Invites read from /join/<token>. - All api/ fetches and the app.js/style.css refs are now root-absolute so a deep path doesn't break relative URL resolution. Server (server.go): - New Server.frontend handler: real assets serve directly; every other non-API/auth/share GET returns index.html (SPA fallback), so deep links and refreshes resolve instead of 404ing. Reserved prefixes stay 404s. - Invite links minted as /join/<token> (orgs.go). Tests: TestFrontendSPAFallback covers the fallback + reserved-prefix 404s; existing invite test updated. Verified end to end in the running hub (deep-link reload, back/forward, no %2F/# in the bar). Docs updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
283 lines
8.9 KiB
Go
283 lines
8.9 KiB
Go
package webapp
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// doAs is do() with a session cookie.
|
|
func doAs(t *testing.T, h http.Handler, method, url string, body any, c *http.Cookie) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var rd *bytes.Reader
|
|
switch b := body.(type) {
|
|
case nil:
|
|
rd = bytes.NewReader(nil)
|
|
case []byte:
|
|
rd = bytes.NewReader(b)
|
|
default:
|
|
data, err := json.Marshal(b)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rd = bytes.NewReader(data)
|
|
}
|
|
req := httptest.NewRequest(method, url, rd)
|
|
if c != nil {
|
|
req.AddCookie(c)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestOrgDB(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "orgs.json")
|
|
db, err := OpenOrgDB(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
o, err := db.Create("acme", "Alice@Example.com")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if db.Role(o.ID, "alice@example.com") != RoleOwner {
|
|
t.Fatal("creator must be owner (email case-insensitive)")
|
|
}
|
|
if err := db.AddMember(o.ID, "bob@example.com", RoleMember); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// an invite never downgrades an owner
|
|
if err := db.AddMember(o.ID, "alice@example.com", RoleMember); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if db.Role(o.ID, "alice@example.com") != RoleOwner {
|
|
t.Fatal("AddMember downgraded the owner")
|
|
}
|
|
|
|
inv, err := db.CreateInvite(o.ID, "alice@example.com", 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, ok := db.Redeem(inv.Token); !ok || got.Org != o.ID {
|
|
t.Fatal("live invite must redeem")
|
|
}
|
|
// expired invites don't
|
|
old, _ := db.CreateInvite(o.ID, "alice@example.com", time.Nanosecond)
|
|
time.Sleep(time.Millisecond)
|
|
if _, ok := db.Redeem(old.Token); ok {
|
|
t.Fatal("expired invite redeemed")
|
|
}
|
|
|
|
// everything persists across a reopen
|
|
db2, err := OpenOrgDB(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if db2.Role(o.ID, "bob@example.com") != RoleMember {
|
|
t.Fatal("membership lost on reload")
|
|
}
|
|
if _, ok := db2.Redeem(inv.Token); !ok {
|
|
t.Fatal("invite lost on reload")
|
|
}
|
|
}
|
|
|
|
// A pre-org hub must keep working after upgrade: every project lands in a
|
|
// default org, every existing account keeps access, oldest account owns it.
|
|
func TestMigrateOrgs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
projects, _ := OpenProjectDB(filepath.Join(dir, "projects.json"))
|
|
p1, _, _ := projects.GetOrCreate("one", "")
|
|
p2, _, _ := projects.GetOrCreate("two", "")
|
|
orgs, _ := OpenOrgDB(filepath.Join(dir, "orgs.json"))
|
|
|
|
accounts := []User{
|
|
{ID: "u-1", Email: "old@x.io"},
|
|
{ID: "u-2", Email: "new@x.io"},
|
|
}
|
|
if err := MigrateOrgs(projects, orgs, accounts); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
def := orgs.OrgsFor("old@x.io")
|
|
if len(def) != 1 || def[0].Name != "default" {
|
|
t.Fatalf("oldest account's orgs = %+v", def)
|
|
}
|
|
if orgs.Role(def[0].ID, "old@x.io") != RoleOwner || orgs.Role(def[0].ID, "new@x.io") != RoleMember {
|
|
t.Fatal("roles: oldest must own, the rest join as members")
|
|
}
|
|
for _, id := range []string{p1.ID, p2.ID} {
|
|
if p, _ := projects.Get(id); p.Org != def[0].ID {
|
|
t.Fatalf("project %s not migrated: %+v", id, p)
|
|
}
|
|
}
|
|
// idempotent: nothing to do the second time
|
|
if err := MigrateOrgs(projects, orgs, accounts); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := orgs.OrgsFor("old@x.io"); len(got) != 1 {
|
|
t.Fatalf("second migration created another org: %+v", got)
|
|
}
|
|
}
|
|
|
|
// orgHub builds an auth+org hub with two accounts in two separate orgs,
|
|
// alice owning a project in hers.
|
|
func orgHub(t *testing.T) (h http.Handler, alice, bob *http.Cookie, pa Project) {
|
|
h, _, alice, bob, pa = orgHubSrv(t)
|
|
return
|
|
}
|
|
|
|
// orgHubSrv also exposes the Server for tests that tweak it (quota).
|
|
func orgHubSrv(t *testing.T) (h http.Handler, srv *Server, alice, bob *http.Cookie, pa Project) {
|
|
t.Helper()
|
|
srv, _, _ = newHub(t, true, nil)
|
|
auth, err := OpenBuiltinAuth(filepath.Join(t.TempDir(), "auth.json"), true, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.Auth = auth
|
|
orgs, err := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.Orgs = orgs
|
|
shares, err := OpenShareDB(filepath.Join(t.TempDir(), "shares.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.Shares = shares
|
|
h = srv.Handler()
|
|
|
|
alice = signupAndSession(t, h, "alice@x.io", "Alice", "password1")
|
|
bob = signupAndSession(t, h, "bob@x.io", "Bob", "password1")
|
|
|
|
// alice creates a project; with no org yet, she gets one of her own
|
|
rec := doAs(t, h, "POST", "/api/projects", map[string]string{"name": "wiki"}, alice)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("create project: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var out struct {
|
|
Project Project `json:"project"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Project.Org == "" {
|
|
t.Fatal("project created without an org")
|
|
}
|
|
return h, srv, alice, bob, out.Project
|
|
}
|
|
|
|
// Every per-project route must reject a non-member and admit a member.
|
|
func TestOrgWallsProjectRoutes(t *testing.T) {
|
|
h, alice, bob, pa := orgHub(t)
|
|
base := "/api/p/" + pa.ID + "/"
|
|
|
|
routes := []struct {
|
|
method, url string
|
|
body any
|
|
}{
|
|
{"GET", base + "tree", nil},
|
|
{"GET", base + "file?path=x.md", nil},
|
|
{"GET", base + "download?path=x.md", nil},
|
|
{"GET", base + "render?path=x.md", nil},
|
|
{"GET", base + "history", nil},
|
|
{"GET", base + "blob?sha=" + strings.Repeat("a", 64), nil},
|
|
{"GET", base + "shares", nil},
|
|
{"POST", base + "shares", map[string]string{"path": "x.md"}},
|
|
{"POST", base + "upload/init", map[string]any{"path": "x.md", "sha256": strings.Repeat("a", 64), "size": 1}},
|
|
{"PUT", base + "upload/content?path=x.md", []byte("hi")},
|
|
{"POST", base + "upload/commit", map[string]any{"path": "x.md", "sha256": strings.Repeat("a", 64), "size": 1}},
|
|
{"GET", base + "store/list?prefix=journal/", nil},
|
|
{"GET", base + "store/object?key=journal/d.jsonl", nil},
|
|
{"GET", base + "store/exists?key=journal/d.jsonl", nil},
|
|
{"POST", base + "store/sign", map[string]any{"key": "blobs/" + strings.Repeat("a", 64), "size": 1}},
|
|
{"PUT", base + "store/object?key=journal/d.jsonl", []byte("{}")},
|
|
}
|
|
for _, rt := range routes {
|
|
if rec := doAs(t, h, rt.method, rt.url, rt.body, bob); rec.Code != http.StatusForbidden {
|
|
t.Errorf("%s %s as non-member: %d, want 403", rt.method, rt.url, rec.Code)
|
|
}
|
|
if rec := doAs(t, h, rt.method, rt.url, rt.body, alice); rec.Code == http.StatusForbidden || rec.Code == http.StatusUnauthorized {
|
|
t.Errorf("%s %s as member: %d, want access", rt.method, rt.url, rec.Code)
|
|
}
|
|
}
|
|
|
|
// the project list shows it only to members; direct get 404s for bob
|
|
if rec := doAs(t, h, "GET", "/api/projects", nil, bob); strings.Contains(rec.Body.String(), pa.ID) {
|
|
t.Error("non-member sees the project in the list")
|
|
}
|
|
if rec := doAs(t, h, "GET", "/api/projects", nil, alice); !strings.Contains(rec.Body.String(), pa.ID) {
|
|
t.Error("member does not see the project in the list")
|
|
}
|
|
if rec := doAs(t, h, "GET", "/api/projects/"+pa.ID, nil, bob); rec.Code != http.StatusNotFound {
|
|
t.Errorf("non-member project get: %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
// An invite is minted by an owner, rejected for non-owners, and joining
|
|
// opens the wall.
|
|
func TestOrgInviteFlow(t *testing.T) {
|
|
h, alice, bob, pa := orgHub(t)
|
|
|
|
// only the owner can mint
|
|
if rec := doAs(t, h, "POST", "/api/orgs/"+pa.Org+"/invites", nil, bob); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("non-owner minted an invite: %d", rec.Code)
|
|
}
|
|
rec := doAs(t, h, "POST", "/api/orgs/"+pa.Org+"/invites", nil, alice)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("mint: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var inv struct {
|
|
Token string `json:"token"`
|
|
URL string `json:"url"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &inv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(inv.URL, "/join/"+inv.Token) {
|
|
t.Fatalf("invite URL = %q", inv.URL)
|
|
}
|
|
|
|
// bob joins, and the project opens up
|
|
if rec := doAs(t, h, "POST", "/api/invites/"+inv.Token, nil, bob); rec.Code != 200 {
|
|
t.Fatalf("accept: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if rec := doAs(t, h, "GET", "/api/p/"+pa.ID+"/tree", nil, bob); rec.Code != 200 {
|
|
t.Fatalf("tree after joining: %d %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
// a bogus token doesn't
|
|
if rec := doAs(t, h, "POST", "/api/invites/deadbeef", nil, bob); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("bogus invite: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// Two orgs can each have a project with the same name.
|
|
func TestProjectNamesScopedToOrg(t *testing.T) {
|
|
h, alice, bob, pa := orgHub(t)
|
|
rec := doAs(t, h, "POST", "/api/projects", map[string]string{"name": pa.Name}, bob)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("bob create: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var out struct {
|
|
Project Project `json:"project"`
|
|
Created bool `json:"created"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &out)
|
|
if !out.Created || out.Project.ID == pa.ID {
|
|
t.Fatalf("bob must get his own %q, got %+v", pa.Name, out)
|
|
}
|
|
// while alice re-joins hers
|
|
rec = doAs(t, h, "POST", "/api/projects", map[string]string{"name": pa.Name}, alice)
|
|
json.Unmarshal(rec.Body.Bytes(), &out)
|
|
if out.Created || out.Project.ID != pa.ID {
|
|
t.Fatalf("alice must re-join %s, got %+v", pa.ID, out)
|
|
}
|
|
_ = alice
|
|
}
|