agentbbs/internal/files/files_test.go
Anthony Ettinger a2d4817a8e files: per-user public at ~name/public; home = member directory
Re-model the web file host as a file server (not a website host):

- Drop the misnamed /site area. A member's public files are now their
  /me/public subfolder (unix ~/public), served anonymously at
  ~<name>/public. The rest of /me stays private; only ~name/public is
  ever exposed. Bare /~name redirects to /~name/public/.
- The root / is now a directory of ALL members, each linked to their BBS
  site (https://<bbs-host>/~name via WebConfig.SiteBase) AND their public
  files here (~name/public). No longer hides empty members.
- Sites/homepages stay on the BBS — files.<host> only links to them.
- Usage gauge is just /me again (which includes /me/public).

setup.sh + docs updated; tests cover ~name/public browse/download, the
bare-~name redirect, empty-member empty-listing, /public-only exposure,
and traversal confinement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 01:21:00 +00:00

213 lines
6 KiB
Go

package files
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/pkg/sftp"
"github.com/profullstack/agentbbs/internal/store"
)
func newTestService(t *testing.T) (*Service, store.Store, store.User) {
t.Helper()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
svc, err := New(st, Config{Root: filepath.Join(dir, "files"), DefaultQuota: 1 << 20})
if err != nil {
t.Fatalf("New: %v", err)
}
u, err := st.EnsureUser("alice", "member", "SHA256:alicekey")
if err != nil {
t.Fatalf("EnsureUser: %v", err)
}
return svc, st, u
}
func TestResolveConfinement(t *testing.T) {
svc, _, u := newTestService(t)
sess, err := svc.newSession(u)
if err != nil {
t.Fatal(err)
}
priv := svc.privRoot(u.Name)
pub := svc.pubRoot()
// These must never resolve to a path outside their area root. Some are
// expected to error outright; for the rest, assert containment.
escapes := []string{
"/me/../../../etc/passwd",
"/me/../../etc",
"/me/sub/../../../../etc/shadow",
"/public/../me/secret",
"/public/../../etc",
"/../etc/passwd",
"/me/./../../public/../../root",
}
for _, p := range escapes {
res, err := sess.resolve(p)
if err != nil {
continue // rejected outright — fine
}
if res.root {
continue // collapsed to the synthetic root — fine
}
ok := within(priv, res.real) || within(pub, res.real)
if !ok {
t.Errorf("resolve(%q) escaped: %q (priv=%q pub=%q)", p, res.real, priv, pub)
}
}
}
func TestResolveAreas(t *testing.T) {
svc, _, u := newTestService(t)
sess, _ := svc.newSession(u)
root, _ := sess.resolve("/")
if !root.root {
t.Error("/ should be the synthetic root")
}
me, err := sess.resolve("/me/notes.txt")
if err != nil || me.area != areaMe || !me.writable {
t.Errorf("/me should be writable me-area: %+v err=%v", me, err)
}
if !within(svc.privRoot(u.Name), me.real) {
t.Errorf("/me path %q not under priv root", me.real)
}
pub, err := sess.resolve("/public/shared.txt")
if err != nil || pub.area != areaPublic {
t.Errorf("/public should be public area: %+v err=%v", pub, err)
}
if _, err := sess.resolve("/etc/passwd"); err == nil {
t.Error("unknown top-level area should be rejected")
}
}
func TestSymlinkEscapeBlocked(t *testing.T) {
svc, _, u := newTestService(t)
sess, _ := svc.newSession(u)
priv := svc.privRoot(u.Name)
// Plant a symlink inside the workspace pointing at the system root.
link := filepath.Join(priv, "escape")
if err := os.Symlink("/etc", link); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
if _, err := sess.resolve("/me/escape/passwd"); err == nil {
t.Error("path through an escaping symlink must be rejected")
}
}
func TestPublicWriteACL(t *testing.T) {
svc, st, u := newTestService(t)
// Default: members may write to the public area.
sess, _ := svc.newSession(u)
r := sftp.NewRequest("Mkdir", "/public/uploads")
if err := sess.Filecmd(r); err != nil {
t.Fatalf("public mkdir should succeed by default: %v", err)
}
// Turn public write off → writes denied, reads still fine.
if err := svc.SetPublicWrite(false); err != nil {
t.Fatal(err)
}
_ = st
sess2, _ := svc.newSession(u)
if err := sess2.Filecmd(sftp.NewRequest("Mkdir", "/public/more")); err != sftp.ErrSSHFxPermissionDenied {
t.Errorf("public write should be denied when off, got %v", err)
}
res, err := sess2.resolve("/public/uploads")
if err != nil || res.writable {
t.Errorf("public should resolve read-only when write is off: %+v err=%v", res, err)
}
}
func TestQuotaEnforced(t *testing.T) {
svc, _, u := newTestService(t)
sess, _ := svc.newSession(u)
sess.quota = 100 // tiny
f, err := os.Create(filepath.Join(svc.privRoot(u.Name), "big"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
w := &quotaWriter{f: f, sess: sess}
if _, err := w.WriteAt(make([]byte, 80), 0); err != nil {
t.Fatalf("write within quota failed: %v", err)
}
if _, err := w.WriteAt(make([]byte, 80), 80); err != sftp.ErrSSHFxFailure {
t.Errorf("write over quota should fail, got %v", err)
}
}
func TestUsage(t *testing.T) {
svc, _, u := newTestService(t)
if err := svc.ensureWorkspace(u.Name); err != nil {
t.Fatal(err)
}
want := []byte(strings.Repeat("x", 512))
if err := os.WriteFile(filepath.Join(svc.privRoot(u.Name), "a.txt"), want, 0o644); err != nil {
t.Fatal(err)
}
usage, err := svc.Usage(u)
if err != nil {
t.Fatal(err)
}
if usage.Bytes != 512 {
t.Errorf("usage = %d, want 512", usage.Bytes)
}
if usage.Quota != 1<<20 {
t.Errorf("quota = %d, want default", usage.Quota)
}
}
func TestUsageCountsPublicHome(t *testing.T) {
svc, _, u := newTestService(t)
if err := svc.ensurePublicHome(u.Name); err != nil {
t.Fatal(err)
}
// A member's ~/public folder lives inside their private /me home, so both the
// top-level private file and the public file count toward the quota gauge;
// the shared /public area does not.
if err := os.WriteFile(filepath.Join(svc.privRoot(u.Name), "a.txt"), []byte(strings.Repeat("x", 512)), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(svc.publicHome(u.Name), "b.txt"), []byte(strings.Repeat("y", 256)), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(svc.pubRoot(), "shared.txt"), []byte(strings.Repeat("z", 9999)), 0o644); err != nil {
t.Fatal(err)
}
usage, err := svc.Usage(u)
if err != nil {
t.Fatal(err)
}
if usage.Bytes != 768 {
t.Errorf("usage = %d, want 768 (512 /me + 256 /me/public, shared /public excluded)", usage.Bytes)
}
}
func TestRevokeBlocksAndQuotaOverride(t *testing.T) {
svc, st, u := newTestService(t)
if err := st.SetFilesQuota(u.ID, 4096); err != nil {
t.Fatal(err)
}
if got := svc.quotaFor(u.ID); got != 4096 {
t.Errorf("quotaFor = %d, want 4096 override", got)
}
if err := st.SetFilesRevoked(u.ID, true); err != nil {
t.Fatal(err)
}
fa, err := st.FilesAccess(u.ID)
if err != nil || !fa.Revoked {
t.Errorf("FilesAccess revoked = %+v err=%v", fa, err)
}
}