mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* fix(webapp): viewer credits the signed-in account, like History does RemoteSource.Files built each FileInfo from op.Author alone and dropped op.User/op.UserName, so the file viewer header showed a git/OS identity the user never signed in with while History rows for the same op showed the account. Carry both fields through FileInfo -> Node -> /render and render them with the whoChanged() helper History already uses, so there is one attribution rule in the frontend rather than two. The guard on the meta line stays on the raw fields: whoChanged() answers "unknown" rather than "", and plain-folder (DirSource) mode has no identity at all and must keep printing nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(webapp): plain-folder render carries no empty identity fields Locks the other half of BEA-37: DirSource has no account behind a file, and sending empty user fields would make whoChanged() print "unknown" where plain-folder mode has always printed nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
149 lines
5.2 KiB
Go
149 lines
5.2 KiB
Go
package webapp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func dirServer(t *testing.T, files map[string]string) http.Handler {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
for rel, content := range files {
|
|
abs := filepath.Join(root, filepath.FromSlash(rel))
|
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
s := &Server{Source: &DirSource{Root: root}, Volume: "local", Refresh: 0}
|
|
return s.Handler()
|
|
}
|
|
|
|
func TestDirSourceServesFolder(t *testing.T) {
|
|
h := dirServer(t, map[string]string{
|
|
"README.md": "# Local",
|
|
"notes/plan.md": "content",
|
|
".bdrive": `{"volume":"x"}`, // settings file must be hidden
|
|
".git/config": "noise", // .git must be skipped
|
|
})
|
|
|
|
var root Node
|
|
if err := json.Unmarshal(get(t, h, "/api/tree").Body.Bytes(), &root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
names := []string{}
|
|
for _, n := range root.Children {
|
|
names = append(names, n.Name)
|
|
}
|
|
if len(root.Children) != 2 || root.Children[0].Name != "notes" || root.Children[1].Name != "README.md" {
|
|
t.Fatalf("tree children = %v, want [notes README.md]", names)
|
|
}
|
|
|
|
rec := get(t, h, "/api/file?path=notes/plan.md")
|
|
if rec.Code != 200 || rec.Body.String() != "content" {
|
|
t.Fatalf("file: %d %q", rec.Code, rec.Body)
|
|
}
|
|
if rec.Header().Get("ETag") == "" {
|
|
t.Fatal("dir source should still produce ETags")
|
|
}
|
|
|
|
rec = get(t, h, "/api/render?path=README.md")
|
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Local") {
|
|
t.Fatalf("render: %d %s", rec.Code, rec.Body)
|
|
}
|
|
// A plain folder has no account behind a file. Sending empty user
|
|
// fields would make the viewer's attribution helper print "unknown"
|
|
// where this mode has always printed nothing.
|
|
if strings.Contains(rec.Body.String(), `"user"`) || strings.Contains(rec.Body.String(), `"user_name"`) {
|
|
t.Errorf("plain-folder render carries identity: %s", rec.Body)
|
|
}
|
|
|
|
if rec := get(t, h, "/api/file?path=.git/config"); rec.Code != 404 {
|
|
t.Fatalf(".git content must be hidden, got %d", rec.Code)
|
|
}
|
|
if rec := get(t, h, "/api/file?path=../escape"); rec.Code != 404 {
|
|
t.Fatalf("path traversal must 404, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// Synced HTML served inline must never run with the hub origin's session:
|
|
// the file endpoint sandboxes it (same posture as /s/* shares). Downloads
|
|
// are exempt — an attachment never executes in the hub's origin.
|
|
func TestInlineHTMLIsSandboxed(t *testing.T) {
|
|
h := dirServer(t, map[string]string{
|
|
"page.html": "<h1>hi</h1><script>1</script>",
|
|
"pic.svg": "<svg xmlns='http://www.w3.org/2000/svg'/>",
|
|
"plan.md": "# md",
|
|
})
|
|
for path, wantCSP := range map[string]bool{
|
|
"/api/file?path=page.html": true,
|
|
"/api/file?path=pic.svg": true,
|
|
"/api/file?path=plan.md": false,
|
|
"/api/download?path=page.html": false, // attachment, not rendered
|
|
} {
|
|
rec := get(t, h, path)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("%s: %d", path, rec.Code)
|
|
}
|
|
csp := rec.Header().Get("Content-Security-Policy")
|
|
if wantCSP && csp != "sandbox allow-scripts" {
|
|
t.Errorf("%s: CSP = %q, want sandbox", path, csp)
|
|
}
|
|
if !wantCSP && csp != "" {
|
|
t.Errorf("%s: unexpected CSP %q", path, csp)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The frontend serves real assets directly but returns the app shell for any
|
|
// client-side route (a deep file path, /join/<token>), so a deep link or
|
|
// refresh doesn't 404. Reserved API/auth/share prefixes stay real 404s.
|
|
func TestFrontendSPAFallback(t *testing.T) {
|
|
h := dirServer(t, map[string]string{"notes/plan.md": "content"})
|
|
|
|
shell := func(url string) {
|
|
t.Helper()
|
|
rec := get(t, h, url)
|
|
if rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
|
|
t.Fatalf("%s: want 200 html, got %d %s", url, rec.Code, rec.Header().Get("Content-Type"))
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `id="root"`) {
|
|
t.Fatalf("%s: expected the app shell, got %.60q", url, rec.Body.String())
|
|
}
|
|
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache" {
|
|
t.Fatalf("%s: the shell must revalidate, got Cache-Control %q", url, cc)
|
|
}
|
|
}
|
|
// Client routes all resolve to the shell, not a 404 or file content.
|
|
shell("/")
|
|
shell("/notes/plan.md") // a deep file route (not the raw file)
|
|
shell("/p-deadbeef/notes/plan.md") // hub-style route
|
|
shell("/join/abc123") // invite route
|
|
|
|
// Real assets are served as themselves, cacheable forever: Vite emits
|
|
// content-hashed filenames, so find one instead of hardcoding a hash.
|
|
assets, err := fs.Glob(staticFiles, "static/assets/*.js")
|
|
if err != nil || len(assets) == 0 {
|
|
t.Fatalf("no built js asset embedded (run npm run build in frontend/): %v", err)
|
|
}
|
|
rec := get(t, h, strings.TrimPrefix(assets[0], "static"))
|
|
if rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "javascript") {
|
|
t.Fatalf("%s: %d %s", assets[0], rec.Code, rec.Header().Get("Content-Type"))
|
|
}
|
|
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
|
|
t.Fatalf("hashed assets must be immutable, got Cache-Control %q", cc)
|
|
}
|
|
|
|
// A mistyped API path is a genuine 404, not the shell.
|
|
if rec := get(t, h, "/api/bogus"); rec.Code != 404 {
|
|
t.Fatalf("/api/bogus: want 404, got %d", rec.Code)
|
|
}
|
|
}
|