agentbbs/internal/auth/auth_test.go
Anthony Ettinger 6dc94bd784 feat(files): SFTP member storage — private workspaces + shared public area + mgmt TUI
Implements M4 (Files). A fully virtual Go SFTP server (pkg/sftp + crypto/ssh,
no OS users) wired as an "sftp" subsystem on the existing :22 wish listener, so
members reach their files with their login key:

    sftp files@bbs.profullstack.com     # scp/rsync ride the same endpoint

Identity is the SSH key (the username is conventional/ignored). Two areas per
session: a private, quota-limited /me workspace and a single shared public file
area /public (old-school BBS file area; world-read, members-only write by
default, operator-moderated). This reverses the old NG1 "no sharing" boundary in
favour of one sanctioned, inspectable sharing surface (PRD §9.3 amended).

internal/files:
- backend.go  service, layout, quota/usage, live-session registry, operator API
- fs.go       per-session virtual FS; resolve() is the single security
              chokepoint (area confinement + symlink-escape guard) + pkg/sftp
              request handlers
- server.go   subsystem handler: key auth -> member session -> request server,
              with byte metering and force-disconnect
- tui.go      in-BBS member browser (hub plugin "Files")
- admin.go    operator management TUI: sessions, workspaces/quotas, public area

Operator console: ssh sftp@<host> (allowlist-gated; sftpadmin@/filesadmin@
aliases) — list/disconnect sessions, set per-user quotas, revoke SFTP access,
toggle public write, moderate the public area.

store: files_access (per-user quota override + revoked) and files_settings
(public-write mode) tables + methods. main.go wiring guarded by AGENTBBS_FILES
(+ AGENTBBS_FILES_QUOTA_MB, default 1 GiB). Route names reserved.

Tests (incl -race): path traversal/confinement, symlink-escape rejection,
public-write ACL, quota enforcement, usage accounting, and an end-to-end run
against a real SFTP client. Docs: docs/files.md; PRD §5.3/§5.3.1/§9.3 + README
updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:35:04 +00:00

108 lines
3.2 KiB
Go

package auth
import "testing"
func TestIsAdminName(t *testing.T) {
for _, name := range []string{"admin", "ADMIN", "sysop"} {
if !IsAdminName(name) {
t.Errorf("IsAdminName(%q) = false, want true", name)
}
}
for _, name := range []string{"bbs", "pod", "anthony", ""} {
if IsAdminName(name) {
t.Errorf("IsAdminName(%q) = true, want false", name)
}
}
}
func TestAdminsAllowlist(t *testing.T) {
t.Setenv("AGENTBBS_ADMINS", "anthony, Root ops")
admins := Admins()
for _, want := range []string{"anthony", "root", "ops"} {
if !admins[want] {
t.Errorf("expected %q in allowlist, got %v", want, admins)
}
}
if !IsAdmin("ANTHONY") {
t.Error("IsAdmin should be case-insensitive")
}
if IsAdmin("eve") {
t.Error("eve must not be an admin")
}
}
func TestAdminsEmpty(t *testing.T) {
t.Setenv("AGENTBBS_ADMINS", "")
if len(Admins()) != 0 {
t.Error("empty env should yield no admins")
}
if IsAdmin("anyone") {
t.Error("nobody is admin when allowlist is empty")
}
}
func TestSanitizeUsername(t *testing.T) {
cases := []struct {
in string
want string
ok bool
}{
{"anthony", "anthony", true},
{" Cool_Name 42 ", "cool-name-42", true},
{"a--b__c", "a-b-c", true},
{"-Edge--", "edge", true},
{"MixedCASE", "mixedcase", true},
{"ab", "ab", false}, // too short
{"!!", "", false}, // nothing usable
{"this-name-is-way-too-long-to-accept", "", false}, // >20 after... actually long
{"admin", "admin", false}, // reserved (route/infra)
{"pod", "pod", false}, // reserved route
{"video-7f3a", "video-7f3a", false}, // reserved call route
{"WWW", "www", false}, // reserved infra label
}
for _, c := range cases {
got, ok := SanitizeUsername(c.in)
if ok != c.ok {
t.Errorf("SanitizeUsername(%q) ok=%v, want %v (got name %q)", c.in, ok, c.ok, got)
}
// For valid results the cleaned name must match; for invalid ones we
// only assert the usability flag (the cleaned form is advisory).
if c.ok && got != c.want {
t.Errorf("SanitizeUsername(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestIsReservedName(t *testing.T) {
for _, n := range []string{"admin", "bbs", "pod", "join", "domain", "agent", "www", "video", "video-abc", "ROOT"} {
if !IsReservedName(n) {
t.Errorf("IsReservedName(%q) = false, want true", n)
}
}
for _, n := range []string{"anthony", "cool-name-42", "member-zafztqdk"} {
if IsReservedName(n) {
t.Errorf("IsReservedName(%q) = true, want false", n)
}
}
}
func TestIsFilesAdminName(t *testing.T) {
for _, name := range []string{"sftp", "SFTP", "sftpadmin", "filesadmin"} {
if !IsFilesAdminName(name) {
t.Errorf("IsFilesAdminName(%q) = false, want true", name)
}
}
for _, name := range []string{"files", "bbs", "anthony", ""} {
if IsFilesAdminName(name) {
t.Errorf("IsFilesAdminName(%q) = true, want false", name)
}
}
}
func TestFilesAdminNamesReserved(t *testing.T) {
for _, name := range []string{"sftp", "sftpadmin", "filesadmin", "mail"} {
if !IsReservedName(name) {
t.Errorf("IsReservedName(%q) = false, want true (route name)", name)
}
}
}