Files
user 1a4d750a4e feat: 增加开发/生产启动脚本,默认内网监听,并修复账号根 PROPFIND 404
默认监听改为 0.0.0.0:18900,内网设备可直接访问;sso 模式仍要求绑回环。
启动日志不再把 0.0.0.0 写成 http://0.0.0.0。

新增 start-dev.sh 与 start-prod.sh(后台 nohup,不占用终端),公共逻辑在
start-lib.sh。开发脚本可按源码指纹决定是否编译;生产脚本在普通启动和
注册 systemd 时同样比对二进制,不一致则 make build。无完整源码时只用现有
二进制。指纹与 pid/日志写入 .run/(已 gitignore)。

生产脚本交互菜单支持后台启动、注册/取消系统服务与开机自启。占用端口时:
本仓库开发或脚本实例先停再接管默认 18900;其它进程才改用相邻端口。临时
换端口不写回下次默认地址。启动信息为每个内网地址同时打印管理页和文件页。

WebDAV 账号根无尾斜杠(如 /ccswitch)会按 /ccswitch/ 处理,避免客户端
PROPFIND 得到 404。补充对应测试。README 同步启动、部署与 systemd 说明。
2026-10-03 11:39:14 +08:00

334 lines
11 KiB
Go

package server
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"github.com/YLing2024/davbox/internal/account"
"github.com/YLing2024/davbox/internal/auth"
)
const testAdminPass = "test-admin-pass"
func newTestServer(t *testing.T) (*Server, *account.Store, string) {
t.Helper()
dir := t.TempDir()
store, err := account.Open(dir)
if err != nil {
t.Fatal(err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(testAdminPass), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
signer := auth.NewSigner([]byte("0123456789abcdef0123456789abcdef"))
return New(Config{DataDir: dir, Store: store, AdminHash: hash, Signer: signer}), store, dir
}
func request(s *Server, method, target, user, pass string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, body)
if user != "" {
req.SetBasicAuth(user, pass)
}
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
return rec
}
func TestDAVUnauthenticatedChallenge(t *testing.T) {
s, store, _ := newTestServer(t)
_, _, _ = store.Create("app1", false, "")
rec := request(s, "PROPFIND", "/app1/", "", "", nil, map[string]string{"Depth": "0"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("未认证应为 401,实际 %d", rec.Code)
}
if got := rec.Header().Get("WWW-Authenticate"); got != `Basic realm="davbox"` {
t.Fatalf("缺少或错误的 WWW-Authenticate: %q", got)
}
}
func TestDAVWrongPassword(t *testing.T) {
s, store, _ := newTestServer(t)
_, _, _ = store.Create("app1", false, "")
rec := request(s, "PROPFIND", "/app1/", "app1", "nope", nil, map[string]string{"Depth": "0"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("错口令应为 401,实际 %d", rec.Code)
}
if rec.Header().Get("WWW-Authenticate") == "" {
t.Fatal("错口令响应也应带 WWW-Authenticate")
}
}
func TestDAVDisabledAccountRejected(t *testing.T) {
s, store, _ := newTestServer(t)
_, pass, _ := store.Create("app1", false, "")
disabled := true
if _, err := store.Update("app1", account.Patch{Disabled: &disabled}); err != nil {
t.Fatal(err)
}
rec := request(s, "PROPFIND", "/app1/", "app1", pass, nil, map[string]string{"Depth": "0"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("停用账号应为 401,实际 %d", rec.Code)
}
}
func TestDAVCrossAccount(t *testing.T) {
s, store, _ := newTestServer(t)
_, passA, _ := store.Create("app1", false, "")
_, _, _ = store.Create("app2", false, "")
rec := request(s, "PROPFIND", "/app2/", "app1", passA, nil, map[string]string{"Depth": "0"})
if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden && rec.Code != http.StatusNotFound {
t.Fatalf("跨账号应为 401/403/404,实际 %d", rec.Code)
}
}
func TestDAVPathEscapeRejected(t *testing.T) {
s, store, _ := newTestServer(t)
_, pass, _ := store.Create("app1", false, "")
cases := []string{
"/app1/%2e%2e/secret",
"/app1/..%2fsecret",
"/app1/%2E%2E/secret",
}
for _, target := range cases {
rec := request(s, "PROPFIND", target, "app1", pass, nil, map[string]string{"Depth": "0"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("路径 %s 应为 400,实际 %d", target, rec.Code)
}
}
}
func TestDAVSixVerbs(t *testing.T) {
s, store, dir := newTestServer(t)
_, pass, _ := store.Create("app1", false, "")
rec := request(s, "MKCOL", "/app1/notes", "app1", pass, nil, nil)
if rec.Code != http.StatusCreated {
t.Fatalf("MKCOL 应为 201,实际 %d", rec.Code)
}
rec = request(s, http.MethodPut, "/app1/notes/a.txt", "app1", pass, strings.NewReader("hello-davbox"), nil)
if rec.Code != http.StatusCreated {
t.Fatalf("PUT 应为 201,实际 %d", rec.Code)
}
rec = request(s, http.MethodGet, "/app1/notes/a.txt", "app1", pass, nil, nil)
if rec.Code != http.StatusOK || rec.Body.String() != "hello-davbox" {
t.Fatalf("GET 应为 200 且内容一致,实际 %d %q", rec.Code, rec.Body.String())
}
rec = request(s, "PROPFIND", "/app1/", "app1", pass, nil, map[string]string{"Depth": "0"})
if rec.Code != 207 {
t.Fatalf("PROPFIND Depth:0 应为 207,实际 %d", rec.Code)
}
rec = request(s, "PROPFIND", "/app1", "app1", pass, nil, map[string]string{"Depth": "0"})
if rec.Code != 207 {
t.Fatalf("PROPFIND 账号根无尾斜杠应为 207,实际 %d %s", rec.Code, rec.Body.String())
}
rec = request(s, "PROPFIND", "/app1/notes/", "app1", pass, nil, map[string]string{"Depth": "1"})
if rec.Code != 207 {
t.Fatalf("PROPFIND Depth:1 应为 207,实际 %d", rec.Code)
}
rec = request(s, "MOVE", "/app1/notes/a.txt", "app1", pass, nil, map[string]string{
"Destination": "http://example.com/app1/notes/b.txt",
})
if rec.Code != http.StatusCreated && rec.Code != http.StatusNoContent {
t.Fatalf("MOVE 应为 201/204,实际 %d", rec.Code)
}
// 内容确实只落在 app1 的 root 下。
if _, err := os.Stat(filepath.Join(dir, "data", "app1", "notes", "b.txt")); err != nil {
t.Fatalf("MOVE 结果应落在账号 root: %v", err)
}
rec = request(s, http.MethodDelete, "/app1/notes/b.txt", "app1", pass, nil, nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE 应为 204,实际 %d", rec.Code)
}
}
func TestDAVReadonlyAccount(t *testing.T) {
s, store, dir := newTestServer(t)
_, pass, _ := store.Create("ro", true, "")
if err := os.WriteFile(filepath.Join(dir, "data", "ro", "readme.txt"), []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
rec := request(s, http.MethodPut, "/ro/x.txt", "ro", pass, strings.NewReader("nope"), nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("只读账号 PUT 应为 403,实际 %d", rec.Code)
}
rec = request(s, "MKCOL", "/ro/dir", "ro", pass, nil, nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("只读账号 MKCOL 应为 403,实际 %d", rec.Code)
}
rec = request(s, http.MethodGet, "/ro/readme.txt", "ro", pass, nil, nil)
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
t.Fatalf("只读账号 GET 应为 200,实际 %d %q", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(dir, "data", "ro", "x.txt")); !os.IsNotExist(err) {
t.Fatal("只读账号不应产生文件")
}
}
func TestAdminAPIExplicitFlow(t *testing.T) {
s, _, dir := newTestServer(t)
// 错口令
rec := request(s, http.MethodPost, "/api/admin/login", "", "", strings.NewReader(`{"password":"bad"}`), nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("错口令应为 401,实际 %d", rec.Code)
}
// 正确口令,取 cookie
rec = request(s, http.MethodPost, "/api/admin/login", "", "", strings.NewReader(`{"password":"`+testAdminPass+`"}`), nil)
if rec.Code != http.StatusOK {
t.Fatalf("正确口令应为 200,实际 %d", rec.Code)
}
cookies := rec.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("登录后应下发 cookie")
}
// 带 cookie 访问列表
rec = doWithCookie(s, http.MethodGet, "/api/admin/accounts", cookies, nil)
if rec.Code != http.StatusOK {
t.Fatalf("列表应为 200,实际 %d", rec.Code)
}
// 无 cookie 访问列表
rec = request(s, http.MethodGet, "/api/admin/accounts", "", "", nil, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("未登录列表应为 401,实际 %d", rec.Code)
}
// 建账号
rec = doWithCookie(s, http.MethodPost, "/api/admin/accounts", cookies, strings.NewReader(`{"user":"newapp","readonly":false}`))
if rec.Code != http.StatusCreated {
t.Fatalf("建账号应为 201,实际 %d", rec.Code)
}
var created struct {
User string `json:"user"`
Pass string `json:"pass"`
URL string `json:"url"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
if created.User != "newapp" || created.Pass == "" || created.URL == "" {
t.Fatalf("建账号响应不完整: %+v", created)
}
// 换口令后旧口令失效
var newPass string
rec = doWithCookie(s, http.MethodPost, "/api/admin/accounts/newapp/rotate", cookies, nil)
if rec.Code != http.StatusOK {
t.Fatalf("换口令应为 200,实际 %d", rec.Code)
}
{
var rot struct {
Pass string `json:"pass"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &rot); err != nil {
t.Fatal(err)
}
newPass = rot.Pass
}
if _, ok := s.store.Authenticate("newapp", created.Pass); ok {
t.Fatal("旧口令应失效")
}
if _, ok := s.store.Authenticate("newapp", newPass); !ok {
t.Fatal("新口令应可用")
}
// 删除后列表消失、目录仍在
rec = doWithCookie(s, http.MethodDelete, "/api/admin/accounts/newapp", cookies, nil)
if rec.Code != http.StatusOK {
t.Fatalf("删除应为 200,实际 %d", rec.Code)
}
if _, ok := s.store.Get("newapp"); ok {
t.Fatal("删除后列表应消失")
}
if _, err := os.Stat(filepath.Join(dir, "data", "newapp")); err != nil {
t.Fatalf("删除后目录应保留: %v", err)
}
}
func TestClientAPIExplicitFlow(t *testing.T) {
s, store, _ := newTestServer(t)
_, pass, _ := store.Create("app1", false, "")
// 登录
rec := request(s, http.MethodPost, "/api/client/login", "", "", strings.NewReader(`{"user":"app1","pass":"`+pass+`"}`), nil)
if rec.Code != http.StatusOK {
t.Fatalf("client 登录应为 200,实际 %d", rec.Code)
}
cookies := rec.Result().Cookies()
// 上传
rec = doWithCookie(s, http.MethodPut, "/api/client/raw/hello.txt", cookies, strings.NewReader("client-body"))
if rec.Code != http.StatusCreated {
t.Fatalf("上传应为 201,实际 %d", rec.Code)
}
// 列表
rec = doWithCookie(s, http.MethodGet, "/api/client/list?path=/", cookies, nil)
if rec.Code != http.StatusOK {
t.Fatalf("列表应为 200,实际 %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "hello.txt") {
t.Fatalf("列表应包含上传的文件: %s", rec.Body.String())
}
// 下载内容一致
rec = doWithCookie(s, http.MethodGet, "/api/client/raw/hello.txt", cookies, nil)
if rec.Code != http.StatusOK || rec.Body.String() != "client-body" {
t.Fatalf("下载应为 200 且内容一致,实际 %d %q", rec.Code, rec.Body.String())
}
// 重命名
rec = doWithCookie(s, http.MethodPost, "/api/client/rename", cookies, strings.NewReader(`{"from":"/hello.txt","to":"/renamed.txt"}`))
if rec.Code != http.StatusOK {
t.Fatalf("重命名应为 200,实际 %d", rec.Code)
}
// 删除
rec = doWithCookie(s, http.MethodDelete, "/api/client/entry?path=/renamed.txt", cookies, nil)
if rec.Code != http.StatusOK {
t.Fatalf("删除应为 200,实际 %d", rec.Code)
}
// 路径逃逸
rec = doWithCookie(s, http.MethodGet, "/api/client/list?path=/../", cookies, nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("逃逸路径应为 400,实际 %d", rec.Code)
}
}
func doWithCookie(s *Server, method, target string, cookies []*http.Cookie, body io.Reader) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, body)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
return rec
}