package files import ( "bytes" "io" "mime" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "github.com/profullstack/agentbbs/internal/store" ) // webTestHandler builds the web handler with a stub authenticator that accepts // alice/secret, plus a helper http client that carries the session cookie. func webTestHandler(t *testing.T) (http.Handler, store.User) { t.Helper() svc, _, u := newTestService(t) h := svc.WebHandler(WebConfig{ Title: "files.test", Authenticate: func(user, pass string) (store.User, bool, error) { if strings.HasPrefix(user, "alice") && pass == "secret" { return u, true, nil } return store.User{}, false, nil }, }) return h, u } func TestWebRequiresAuth(t *testing.T) { h, _ := webTestHandler(t) // Unauthenticated root shows the login form, not a file listing. rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) if body := rr.Body.String(); !strings.Contains(body, "Sign in") { t.Fatalf("expected login page, got: %.120s", body) } // API endpoints reject without a session. rr = httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/download?path=/me/x", nil)) if rr.Code != http.StatusUnauthorized { t.Fatalf("download without auth: want 401, got %d", rr.Code) } // Bad credentials do not set a session cookie. rr = httptest.NewRecorder() form := url.Values{"user": {"alice"}, "pass": {"wrong"}} req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") h.ServeHTTP(rr, req) if len(rr.Result().Cookies()) != 0 { t.Fatal("bad login should not set a cookie") } } func TestSecureReqUsesFirstForwardedProto(t *testing.T) { for _, tc := range []struct { name string proto string secure bool }{ {name: "https", proto: "https", secure: true}, {name: "proxy chain", proto: "https, http", secure: true}, {name: "case and whitespace", proto: " HTTPS , http", secure: true}, {name: "http", proto: "http", secure: false}, {name: "untrusted later value", proto: "http, https", secure: false}, } { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Forwarded-Proto", tc.proto) if got := secureReq(req); got != tc.secure { t.Fatalf("secureReq() = %v, want %v for %q", got, tc.secure, tc.proto) } }) } } func TestWebRoundTrip(t *testing.T) { h, _ := webTestHandler(t) // Log in and capture the session cookie. form := url.Values{"user": {"alice@files.test"}, "pass": {"secret"}} rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") h.ServeHTTP(rr, req) cookies := rr.Result().Cookies() if len(cookies) == 0 { t.Fatal("login did not set a cookie") } cookie := cookies[0] auth := func(r *http.Request) { r.AddCookie(cookie) } // Upload a file into /me. var buf bytes.Buffer mw := multipart.NewWriter(&buf) _ = mw.WriteField("dir", "/me") fw, _ := mw.CreateFormFile("file", "hello.txt") _, _ = fw.Write([]byte("hello world")) _ = mw.Close() rr = httptest.NewRecorder() req = httptest.NewRequest(http.MethodPost, "/upload", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) auth(req) h.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Fatalf("upload: want redirect, got %d (%s)", rr.Code, rr.Body.String()) } // The listing now shows the file. rr = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/?path=/me", nil) auth(req) h.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "hello.txt") { t.Fatalf("listing missing uploaded file: %.300s", rr.Body.String()) } // Download returns the bytes. rr = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/download?path=/me/hello.txt", nil) auth(req) h.ServeHTTP(rr, req) if got, _ := io.ReadAll(rr.Body); string(got) != "hello world" { t.Fatalf("download mismatch: %q", got) } // Delete removes it. rr = httptest.NewRecorder() form = url.Values{"path": {"/me/hello.txt"}} req = httptest.NewRequest(http.MethodPost, "/delete", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") auth(req) h.ServeHTTP(rr, req) rr = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/?path=/me", nil) auth(req) h.ServeHTTP(rr, req) if strings.Contains(rr.Body.String(), "hello.txt") { t.Fatal("file still present after delete") } } func TestWebDownloadEncodesUnicodeFilename(t *testing.T) { svc, _, u := newTestService(t) h := svc.WebHandler(WebConfig{ Title: "files.test", Authenticate: func(user, pass string) (store.User, bool, error) { return u, user == "alice" && pass == "secret", nil }, }) cookie := loginCookie(t, h) name := "résumé.txt" if _, _, err := svc.OpenFor(u.Name); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(svc.privRoot(u.Name), name), []byte("complete"), 0o644); err != nil { t.Fatal(err) } rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/download?path="+url.QueryEscape("/me/"+name), nil) req.AddCookie(cookie) h.ServeHTTP(rr, req) disposition := rr.Header().Get("Content-Disposition") if !strings.Contains(disposition, "filename*=") { t.Fatalf("Content-Disposition should encode a Unicode filename: %q", disposition) } mediaType, params, err := mime.ParseMediaType(disposition) if err != nil { t.Fatalf("invalid Content-Disposition: %v", err) } if mediaType != "attachment" || params["filename"] != name { t.Fatalf("Content-Disposition filename: got %q, want %q", params["filename"], name) } } func TestWebMutationsRejectGet(t *testing.T) { h, _ := webTestHandler(t) cookie := loginCookie(t, h) uploadTo(t, h, cookie, "/me", "keep.txt", "keep me") for _, target := range []string{ "/upload?dir=/me", "/mkdir?dir=/me&name=from-get", "/delete?path=/me/keep.txt", } { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, target, nil) req.AddCookie(cookie) h.ServeHTTP(rr, req) if rr.Code != http.StatusMethodNotAllowed { t.Fatalf("GET %s: want 405, got %d", target, rr.Code) } } rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/?path=/me", nil) req.AddCookie(cookie) h.ServeHTTP(rr, req) body := rr.Body.String() if !strings.Contains(body, "keep.txt") { t.Fatal("GET /delete removed the file") } if strings.Contains(body, "from-get") { t.Fatal("GET /mkdir created a directory") } } // loginCookie logs alice in and returns her session cookie. func loginCookie(t *testing.T, h http.Handler) *http.Cookie { t.Helper() form := url.Values{"user": {"alice"}, "pass": {"secret"}} rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") h.ServeHTTP(rr, req) cs := rr.Result().Cookies() if len(cs) == 0 { t.Fatal("login did not set a cookie") } return cs[0] } // uploadTo uploads body to dir as the holder of cookie. func uploadTo(t *testing.T, h http.Handler, cookie *http.Cookie, dir, name, body string) { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) _ = mw.WriteField("dir", dir) fw, _ := mw.CreateFormFile("file", name) _, _ = fw.Write([]byte(body)) _ = mw.Close() rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/upload", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.AddCookie(cookie) h.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Fatalf("upload to %s: want redirect, got %d (%s)", dir, rr.Code, rr.Body.String()) } } func TestWebAnonPublicSite(t *testing.T) { h, _ := webTestHandler(t) cookie := loginCookie(t, h) // alice publishes to her own public area (/public, a sibling of private /me). uploadTo(t, h, cookie, "/public", "hello.txt", "from alice") // The unauthenticated root lists every member with a link to ~alice/public. rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) if body := rr.Body.String(); !strings.Contains(body, "/~alice/public/") { t.Fatalf("index missing ~alice/public link: %.300s", body) } // Anonymous (no cookie) can download a member's published file. rr = httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/public/hello.txt", nil)) if got, _ := io.ReadAll(rr.Body); string(got) != "from alice" { t.Fatalf("~alice/public file: got %q", got) } // Bare ~alice redirects to ~alice/public/. rr = httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice", nil)) if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/~alice/public/" { t.Fatalf("~alice: want redirect to /~alice/public/, got %d %q", rr.Code, rr.Header().Get("Location")) } // Anonymous directory browse renders a listing. rr = httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/public/", nil)) if body := rr.Body.String(); !strings.Contains(body, "hello.txt") { t.Fatalf("~alice/public browse missing file: %.300s", body) } // /me stays private: there is no anonymous route into it. rr = httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/me/", nil)) if rr.Code != http.StatusNotFound { t.Fatalf("~alice/me: want 404 (private), got %d", rr.Code) } } func TestWebAnonMemberSiteEmptyNot404(t *testing.T) { // A registered member who has not published anything yet is reachable at // ~name/public as a browsable listing, not a 404 — materializing the area // seeds a default README.txt, so the listing greets visitors with it rather // than "(empty)". A missing file under them, and an unknown member, both // still 404. svc, st, _ := newTestService(t) if _, err := st.EnsureUser("bob", "member", "SHA256:bobkey"); err != nil { t.Fatal(err) } h := svc.WebHandler(WebConfig{Title: "files.test"}) rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/public/", nil)) if rr.Code != http.StatusOK { t.Fatalf("~bob/public (member, empty): want 200, got %d", rr.Code) } if !strings.Contains(rr.Body.String(), "README.txt") { t.Fatalf("~bob/public should render the seeded README in its listing: %.200s", rr.Body.String()) } rr = httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/public/nope.txt", nil)) if rr.Code != http.StatusNotFound { t.Fatalf("~bob/public/nope.txt: want 404, got %d", rr.Code) } // Anything under ~bob that is not /public is not exposed. rr = httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/secret", nil)) if rr.Code != http.StatusNotFound { t.Fatalf("~bob/secret: want 404, got %d", rr.Code) } rr = httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~nobody/public/", nil)) if rr.Code != http.StatusNotFound { t.Fatalf("~nobody (unknown): want 404, got %d", rr.Code) } } func TestWebAnonCannotEscape(t *testing.T) { h, _ := webTestHandler(t) cookie := loginCookie(t, h) uploadTo(t, h, cookie, "/me", "secret.txt", "private") // Only ~name/public is exposed; traversal out of a public area must not reach // the private /me or anything above it. for _, p := range []string{ "/~alice/public/../../users/alice/secret.txt", "/~alice/public/../../../users/alice/secret.txt", "/~alice/public/..%2f..%2fusers%2falice%2fsecret.txt", "/~alice/me/secret.txt", // /me is private — not an anon surface "/~ghost/public/x", // unknown member } { rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil)) if body, _ := io.ReadAll(rr.Body); strings.Contains(string(body), "private") { t.Fatalf("anon path %q leaked private content", p) } if rr.Code == http.StatusOK && strings.Contains(rr.Body.String(), "secret.txt") { t.Fatalf("anon path %q exposed /me", p) } } // And the authed download API still rejects /me without a session. rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/download?path=/me/secret.txt", nil)) if rr.Code != http.StatusUnauthorized { t.Fatalf("anon /me download: want 401, got %d", rr.Code) } } func TestWebPublicReadOnlyByDefault(t *testing.T) { h, _ := webTestHandler(t) // Log in. form := url.Values{"user": {"alice"}, "pass": {"secret"}} rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") h.ServeHTTP(rr, req) cookie := rr.Result().Cookies()[0] // Default public_write is "members" (writable), so /public should accept an // upload; this asserts the area resolves and writes land — the moderation // toggle is covered in the SFTP tests. var buf bytes.Buffer mw := multipart.NewWriter(&buf) _ = mw.WriteField("dir", "/public") fw, _ := mw.CreateFormFile("file", "note.txt") _, _ = fw.Write([]byte("shared")) _ = mw.Close() rr = httptest.NewRecorder() req = httptest.NewRequest(http.MethodPost, "/upload", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.AddCookie(cookie) h.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Fatalf("public upload: want redirect, got %d", rr.Code) } }