feat(backend): book list/detail/delete with embedded progress + cache cleanup
This commit is contained in:
@@ -19,7 +19,7 @@ import (
|
|||||||
"booklib/internal/store"
|
"booklib/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupAPI(t *testing.T) (*store.Store, http.Handler) {
|
func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
url := os.Getenv("DATABASE_URL")
|
url := os.Getenv("DATABASE_URL")
|
||||||
if url == "" {
|
if url == "" {
|
||||||
@@ -32,20 +32,24 @@ func setupAPI(t *testing.T) (*store.Store, http.Handler) {
|
|||||||
}
|
}
|
||||||
st := store.New(p)
|
st := store.New(p)
|
||||||
p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users")
|
p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users")
|
||||||
h, _ := auth.HashPassword("pw12345")
|
h, _ := auth.HashPassword(testPW)
|
||||||
_, err := st.CreateUser(ctx, "alice", h, "admin")
|
if _, err := st.CreateUser(ctx, "alice", h, "admin"); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err = st.CreateUser(ctx, "bob", h, "member")
|
if _, err := st.CreateUser(ctx, "bob", h, "member"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
booksParent := t.TempDir()
|
||||||
|
booksDir, err := filepath.EvalSymlinks(booksParent) // macOS 上 /var→/private,root 校验要用真实路径
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cfg := testCfg()
|
cfg := testCfg()
|
||||||
cfg.BooksDir = filepath.Clean(os.TempDir())
|
cfg.BooksDir = booksDir
|
||||||
cfg.CacheDir = t.TempDir()
|
cfg.CacheDir = t.TempDir()
|
||||||
rdb := redispkg.New(os.Getenv("REDIS_URL"))
|
rdb := redispkg.New(os.Getenv("REDIS_URL"))
|
||||||
r := NewRouter(cfg, st, rdb, scanner.New(st, cfg, rdb))
|
sc := scanner.New(st, cfg, rdb)
|
||||||
|
r := NewRouter(cfg, st, rdb, sc)
|
||||||
if u := os.Getenv("REDIS_URL"); u != "" { // 测试卫生: 共享 redis 上重置登录限流桶, 防跨测试累计 429
|
if u := os.Getenv("REDIS_URL"); u != "" { // 测试卫生: 共享 redis 上重置登录限流桶, 防跨测试累计 429
|
||||||
if opt, e := redis.ParseURL(u); e == nil {
|
if opt, e := redis.ParseURL(u); e == nil {
|
||||||
rc := redis.NewClient(opt)
|
rc := redis.NewClient(opt)
|
||||||
@@ -53,9 +57,12 @@ func setupAPI(t *testing.T) (*store.Store, http.Handler) {
|
|||||||
rc.Close()
|
rc.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return st, r
|
return st, sc, r, booksDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testPW 是唯一的 fixture 口令常量: 直接种子 (HashPassword) 与所有登录/建户必须同值, 且 >=8 位
|
||||||
|
const testPW = "pw123456"
|
||||||
|
|
||||||
func do(h http.Handler, method, path, token string, body any) *httptest.ResponseRecorder {
|
func do(h http.Handler, method, path, token string, body any) *httptest.ResponseRecorder {
|
||||||
var r *bytes.Reader
|
var r *bytes.Reader
|
||||||
if body != nil {
|
if body != nil {
|
||||||
@@ -74,8 +81,8 @@ func do(h http.Handler, method, path, token string, body any) *httptest.Response
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginMe(t *testing.T) {
|
func TestLoginMe(t *testing.T) {
|
||||||
_, h := setupAPI(t)
|
_, _, h, _ := setupAPI(t)
|
||||||
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": "pw12345"})
|
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": testPW})
|
||||||
if w.Code != 200 {
|
if w.Code != 200 {
|
||||||
t.Fatalf("login %d %s", w.Code, w.Body)
|
t.Fatalf("login %d %s", w.Code, w.Body)
|
||||||
}
|
}
|
||||||
@@ -105,13 +112,12 @@ func TestLoginMe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMemberCannotWriteUsers(t *testing.T) {
|
func TestMemberCannotWriteUsers(t *testing.T) {
|
||||||
_, h := setupAPI(t)
|
_, _, h, _ := setupAPI(t)
|
||||||
tok, _ := auth.Sign([]byte("s3cret"), 2, "member") // bob — 注意: 必须走真实登录拿 token
|
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "bob", "password": testPW})
|
||||||
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "bob", "password": "pw12345"})
|
|
||||||
var v struct{ Token string }
|
var v struct{ Token string }
|
||||||
json.Unmarshal(w.Body.Bytes(), &v)
|
json.Unmarshal(w.Body.Bytes(), &v)
|
||||||
tok = v.Token
|
tok := v.Token
|
||||||
w = do(h, "POST", "/api/users", tok, map[string]string{"username": "eve", "password": "pw12345", "role": "member"})
|
w = do(h, "POST", "/api/users", tok, map[string]string{"username": "eve", "password": testPW, "role": "member"})
|
||||||
if w.Code != 403 {
|
if w.Code != 403 {
|
||||||
t.Fatalf("member write users: want 403 got %d", w.Code)
|
t.Fatalf("member write users: want 403 got %d", w.Code)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"booklib/internal/bookfile"
|
||||||
|
"booklib/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
|
||||||
|
b, perr := a.st.GetBook(c, id)
|
||||||
|
if perr != nil {
|
||||||
|
err(c, http.StatusNotFound, "not_found", "no such book")
|
||||||
|
return store.Book{}, false
|
||||||
|
}
|
||||||
|
return b, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) bookFromParam(c *gin.Context) (store.Book, bool) {
|
||||||
|
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if e != nil {
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
||||||
|
return store.Book{}, false
|
||||||
|
}
|
||||||
|
return a.getBookRow(c, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) getLibRow(c *gin.Context, id int64) (store.Library, bool) {
|
||||||
|
l, e := a.st.GetLibrary(c, id)
|
||||||
|
if e != nil {
|
||||||
|
err(c, http.StatusNotFound, "not_found", "no such library")
|
||||||
|
return store.Library{}, false
|
||||||
|
}
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// absBookPath: books.path 永远相对且不含 ..;拼接后二次前缀校验(纵深防御)
|
||||||
|
func absBookPath(root string, b store.Book) (string, error) {
|
||||||
|
abs := filepath.Join(root, filepath.FromSlash(b.Path))
|
||||||
|
if filepath.Clean(abs) != abs || !hasPrefixDir(abs, root) {
|
||||||
|
return "", os.ErrPermission
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPrefixDir(p, dir string) bool {
|
||||||
|
rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(p))
|
||||||
|
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
||||||
|
}
|
||||||
|
|
||||||
|
func bookJSON(b store.Book, percent float64, libraryName string) gin.H {
|
||||||
|
h := bookfile.Hash(b.FileSize, b.ModTS)
|
||||||
|
j := gin.H{
|
||||||
|
"id": b.ID, "library_id": b.LibraryID, "path": b.Path, "title": b.Title,
|
||||||
|
"format": b.Format, "size": b.FileSize, "mtime": b.ModTS, "pages": b.PageCount,
|
||||||
|
"state": b.State, "error": b.ErrMsg, "added_at": b.AddedAt.Format(time.RFC3339),
|
||||||
|
"percent": percent,
|
||||||
|
"cover_url": fmt.Sprintf("/api/books/%d/cover?v=%s", b.ID, h),
|
||||||
|
}
|
||||||
|
if b.Format == "cbz" {
|
||||||
|
j["pages_url"] = fmt.Sprintf("/api/books/%d/pages", b.ID)
|
||||||
|
j["page_url_fmt"] = fmt.Sprintf("/api/books/%d/pages/%%d?v=%s", b.ID, h)
|
||||||
|
} else {
|
||||||
|
j["file_url"] = fmt.Sprintf("/api/books/%d/file?v=%s", b.ID, h)
|
||||||
|
}
|
||||||
|
if libraryName != "" {
|
||||||
|
j["library"] = libraryName
|
||||||
|
}
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) listBooks(c *gin.Context) {
|
||||||
|
libID, _ := strconv.ParseInt(c.Query("library"), 10, 64)
|
||||||
|
views, e := a.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c))
|
||||||
|
if e != nil {
|
||||||
|
err(c, http.StatusInternalServerError, "internal", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]gin.H, 0, len(views))
|
||||||
|
for _, v := range views {
|
||||||
|
out = append(out, bookJSON(v.Book, v.Percent, v.LibraryName))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) getBook(c *gin.Context) {
|
||||||
|
b, ok := a.bookFromParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, _ := a.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent
|
||||||
|
lib, _ := a.st.GetLibrary(c, b.LibraryID)
|
||||||
|
c.JSON(http.StatusOK, bookJSON(b, p.Percent, lib.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) deleteBook(c *gin.Context) {
|
||||||
|
b, ok := a.bookFromParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lib, ok := a.getLibRow(c, b.LibraryID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root, ok := a.libRoot(c, lib)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, e := absBookPath(root, b)
|
||||||
|
if e != nil {
|
||||||
|
err(c, http.StatusForbidden, "forbidden", "unsafe path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := os.Remove(abs); e != nil && !os.IsNotExist(e) {
|
||||||
|
err(c, http.StatusInternalServerError, "internal", "remove file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
|
||||||
|
os.RemoveAll(bookfile.CoverDir(a.cfg.CacheDir, key))
|
||||||
|
os.RemoveAll(bookfile.PagesDir(a.cfg.CacheDir, key))
|
||||||
|
if e := a.st.DeleteBook(c, b.ID); e != nil {
|
||||||
|
err(c, http.StatusInternalServerError, "internal", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"booklib/internal/scanner"
|
||||||
|
"booklib/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLibrary(t *testing.T, st *store.Store, h http.Handler, tok, booksDir, name string) (store.Library, string) {
|
||||||
|
t.Helper()
|
||||||
|
root := filepath.Join(booksDir, name)
|
||||||
|
os.MkdirAll(filepath.Join(root, "series-a"), 0o755)
|
||||||
|
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": name, "root_path": root})
|
||||||
|
if w.Code != 201 {
|
||||||
|
t.Fatalf("create lib %d %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &v)
|
||||||
|
lib, err := st.GetLibrary(context.Background(), v.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return lib, root
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCBZ(t *testing.T, path string, pages int) {
|
||||||
|
t.Helper()
|
||||||
|
os.MkdirAll(filepath.Dir(path), 0o755)
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
zw := zip.NewWriter(buf)
|
||||||
|
for i := 1; i <= pages; i++ {
|
||||||
|
w, _ := zw.Create(i2name(i))
|
||||||
|
w.Write(bytes.Repeat([]byte("IMG"), 64))
|
||||||
|
}
|
||||||
|
zw.Close()
|
||||||
|
os.WriteFile(path, buf.Bytes(), 0o644)
|
||||||
|
}
|
||||||
|
func i2name(i int) string { return fmt.Sprintf("%02d.jpg", i) }
|
||||||
|
|
||||||
|
func scanNow(t *testing.T, sc *scanner.Scanner, lib store.Library) {
|
||||||
|
t.Helper()
|
||||||
|
sc.ScanLibrary(context.Background(), lib)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBookListDetailDelete(t *testing.T) {
|
||||||
|
st, sc, h, booksDir := setupAPI(t)
|
||||||
|
atok := adminToken(t, h)
|
||||||
|
|
||||||
|
// member token + 进度前置数据
|
||||||
|
do(h, "POST", "/api/users", atok, map[string]string{"username": "m2", "password": testPW, "role": "member"})
|
||||||
|
mtok := loginAs(t, h, "m2", testPW)
|
||||||
|
|
||||||
|
lib, root := newLibrary(t, st, h, atok, booksDir, "comics")
|
||||||
|
writeCBZ(t, filepath.Join(root, "series-a", "vol_01.cbz"), 4)
|
||||||
|
os.WriteFile(filepath.Join(root, "readme.txt"), []byte("hello world"), 0o644)
|
||||||
|
scanNow(t, sc, lib)
|
||||||
|
|
||||||
|
w := do(h, "GET", "/api/books", mtok, nil)
|
||||||
|
var books []map[string]any
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &books)
|
||||||
|
if w.Code != 200 || len(books) != 2 {
|
||||||
|
t.Fatalf("list %d %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
var cbz map[string]any
|
||||||
|
for _, b := range books {
|
||||||
|
if b["format"] == "cbz" {
|
||||||
|
cbz = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cbz == nil {
|
||||||
|
t.Fatal("cbz missing")
|
||||||
|
}
|
||||||
|
if cbz["pages"].(float64) != 4 || cbz["library"] != "comics" || cbz["percent"].(float64) != 0 {
|
||||||
|
t.Fatalf("bad json %+v", cbz)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(cbz["cover_url"].(string), "/api/books/") || !strings.Contains(cbz["cover_url"].(string), "?v=") {
|
||||||
|
t.Fatalf("cover_url %+v", cbz["cover_url"])
|
||||||
|
}
|
||||||
|
if cbz["page_url_fmt"] == nil {
|
||||||
|
t.Fatalf("cbz must have page_url_fmt: %+v", cbz)
|
||||||
|
}
|
||||||
|
id := itoa(cbz["id"])
|
||||||
|
|
||||||
|
// detail
|
||||||
|
w = do(h, "GET", "/api/books/"+id, mtok, nil)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("detail %d", w.Code)
|
||||||
|
}
|
||||||
|
// 进度联动的断言在 Task 12(progress 端点此任务还不存在)
|
||||||
|
|
||||||
|
// member 不能删
|
||||||
|
w = do(h, "DELETE", "/api/books/"+id, mtok, nil)
|
||||||
|
if w.Code != 403 {
|
||||||
|
t.Fatalf("member delete want 403 got %d", w.Code)
|
||||||
|
}
|
||||||
|
// admin 删:行、文件、缓存目录都没;txt 文件保留
|
||||||
|
w = do(h, "DELETE", "/api/books/"+id, atok, nil)
|
||||||
|
if w.Code != 204 {
|
||||||
|
t.Fatalf("admin delete %d %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "series-a", "vol_01.cbz")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("file not removed")
|
||||||
|
}
|
||||||
|
w = do(h, "GET", "/api/books", atok, nil)
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &books)
|
||||||
|
if len(books) != 1 || books[0]["format"] != "txt" {
|
||||||
|
t.Fatalf("books after delete %+v", books)
|
||||||
|
}
|
||||||
|
// 过滤器
|
||||||
|
w = do(h, "GET", "/api/books?library="+itoa(lib.ID)+"&q=readme&prefix=series/", atok, nil)
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &books)
|
||||||
|
if len(books) != 0 {
|
||||||
|
t.Fatalf("prefix+q filter broken %+v", books)
|
||||||
|
}
|
||||||
|
w = do(h, "GET", "/api/books?q=readme", atok, nil)
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &books)
|
||||||
|
if len(books) != 1 {
|
||||||
|
t.Fatalf("q broken %+v", books)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginAs(t *testing.T, h http.Handler, user, pass string) string {
|
||||||
|
t.Helper()
|
||||||
|
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": user, "password": pass})
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("login %s: %d", user, w.Code)
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &v)
|
||||||
|
return v.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbsBookPathTraversalRejected(t *testing.T) {
|
||||||
|
root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB
|
||||||
|
for _, bad := range []string{"../../etc/passwd", "a/../../../etc/x", "../sibling"} {
|
||||||
|
if _, e := absBookPath(root, store.Book{Path: bad}); e == nil {
|
||||||
|
t.Fatalf("must reject %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p, e := absBookPath(root, store.Book{Path: "series-a/vol.cbz"}); e != nil || p != filepath.Join(root, "series-a", "vol.cbz") {
|
||||||
|
t.Fatalf("must accept relative path: %q %v", p, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestLibraryCreateListUpload(t *testing.T) {
|
func TestLibraryCreateListUpload(t *testing.T) {
|
||||||
_, h := setupAPI(t)
|
_, _, h, booksDir := setupAPI(t)
|
||||||
tok := adminToken(t, h)
|
tok := adminToken(t, h)
|
||||||
root := t.TempDir()
|
root := filepath.Join(booksDir, "lib1") // 必须落在解析过软链的 booksDir 内
|
||||||
|
os.MkdirAll(root, 0o755)
|
||||||
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics", "root_path": root})
|
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics", "root_path": root})
|
||||||
if w.Code != 201 {
|
if w.Code != 201 {
|
||||||
t.Fatalf("create lib %d %s", w.Code, w.Body)
|
t.Fatalf("create lib %d %s", w.Code, w.Body)
|
||||||
|
|||||||
@@ -33,5 +33,9 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner
|
|||||||
libs.POST("", a.adminOnly(), a.createLibrary)
|
libs.POST("", a.adminOnly(), a.createLibrary)
|
||||||
libs.POST("/:id/scan", a.adminOnly(), a.scanLibrary)
|
libs.POST("/:id/scan", a.adminOnly(), a.scanLibrary)
|
||||||
libs.POST("/:id/upload", a.adminOnly(), a.upload)
|
libs.POST("/:id/upload", a.adminOnly(), a.upload)
|
||||||
|
|
||||||
|
p.GET("/books", a.listBooks)
|
||||||
|
p.GET("/books/:id", a.getBook)
|
||||||
|
p.DELETE("/books/:id", a.adminOnly(), a.deleteBook)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,11 @@ import (
|
|||||||
|
|
||||||
func adminToken(t *testing.T, h http.Handler) string {
|
func adminToken(t *testing.T, h http.Handler) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": "pw12345"})
|
return loginAs(t, h, "alice", testPW)
|
||||||
var v struct{ Token string }
|
|
||||||
json.Unmarshal(w.Body.Bytes(), &v)
|
|
||||||
return v.Token
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUserCRUD(t *testing.T) {
|
func TestUserCRUD(t *testing.T) {
|
||||||
_, h := setupAPI(t)
|
_, _, h, _ := setupAPI(t)
|
||||||
tok := adminToken(t, h)
|
tok := adminToken(t, h)
|
||||||
w := do(h, "POST", "/api/users", tok, map[string]string{"username": "carol", "password": "pw123456", "role": "member"})
|
w := do(h, "POST", "/api/users", tok, map[string]string{"username": "carol", "password": "pw123456", "role": "member"})
|
||||||
if w.Code != 201 {
|
if w.Code != 201 {
|
||||||
@@ -59,7 +56,7 @@ func TestUserCRUD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBadRoleRejected(t *testing.T) {
|
func TestBadRoleRejected(t *testing.T) {
|
||||||
_, h := setupAPI(t)
|
_, _, h, _ := setupAPI(t)
|
||||||
tok := adminToken(t, h)
|
tok := adminToken(t, h)
|
||||||
w := do(h, "POST", "/api/users", tok, map[string]string{"username": "e", "password": "pw123456", "role": "god"})
|
w := do(h, "POST", "/api/users", tok, map[string]string{"username": "e", "password": "pw123456", "role": "god"})
|
||||||
if w.Code != 400 {
|
if w.Code != 400 {
|
||||||
@@ -76,4 +73,9 @@ func findID(rows []map[string]any, name string) float64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func itoa(v any) string { return strconv.FormatFloat(v.(float64), 'f', 0, 64) }
|
func itoa(v any) string {
|
||||||
|
if i, ok := v.(int64); ok {
|
||||||
|
return strconv.FormatInt(i, 10) // DB 行里的 id (如 store.Library.ID)
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(v.(float64), 'f', 0, 64) // json.Unmarshal 出来的数字
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -39,6 +40,14 @@ func Load() (*Config, error) {
|
|||||||
if secret == "" {
|
if secret == "" {
|
||||||
return nil, fmt.Errorf("JWT_SECRET required")
|
return nil, fmt.Errorf("JWT_SECRET required")
|
||||||
}
|
}
|
||||||
|
// resolveDir: macOS 开发机上 /var、/tmp 是指向 /private 的软链,
|
||||||
|
// 启动时解析一次才能让 root_path 前缀校验对上真实路径;目录不存在/出错则保留原值(Docker 路径不受影响)
|
||||||
|
resolveDir := func(dir string) string {
|
||||||
|
if r, e := filepath.EvalSymlinks(dir); e == nil {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
return &Config{
|
return &Config{
|
||||||
Addr: env("ADDR", ":8080"),
|
Addr: env("ADDR", ":8080"),
|
||||||
DatabaseURL: env("DATABASE_URL", ""),
|
DatabaseURL: env("DATABASE_URL", ""),
|
||||||
@@ -46,8 +55,8 @@ func Load() (*Config, error) {
|
|||||||
JWTSecret: []byte(secret),
|
JWTSecret: []byte(secret),
|
||||||
AdminUser: env("ADMIN_USER", ""),
|
AdminUser: env("ADMIN_USER", ""),
|
||||||
AdminPassword: env("ADMIN_PASSWORD", ""),
|
AdminPassword: env("ADMIN_PASSWORD", ""),
|
||||||
BooksDir: env("BOOKS_DIR", "/data/books"),
|
BooksDir: resolveDir(env("BOOKS_DIR", "/data/books")),
|
||||||
CacheDir: env("CACHE_DIR", "/data/cache"),
|
CacheDir: resolveDir(env("CACHE_DIR", "/data/cache")),
|
||||||
ScanInterval: time.Duration(scanSec) * time.Second,
|
ScanInterval: time.Duration(scanSec) * time.Second,
|
||||||
UploadMaxMB: uploadMB,
|
UploadMaxMB: uploadMB,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user