package handlers_test 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}) 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 TestDeleteUnsafePath403(t *testing.T) { st, _, h, booksDir := setupAPI(t) atok := adminToken(t, h) lib, _ := newLibrary(t, st, h, atok, booksDir, "libs") id, e := st.InsertBook(context.Background(), lib.ID, "../../x.cbz", "x", "cbz", 1, 1, 0) if e != nil { t.Fatal(e) } w := do(h, "DELETE", "/api/books/"+itoa(id), atok, nil) if w.Code != 403 { t.Fatalf("unsafe delete want 403 got %d %s", w.Code, w.Body) } if _, e := st.GetBook(context.Background(), id); e != nil { // 403 提前返回,行必须保留 t.Fatalf("row must survive: %v", e) } }