740 lines
28 KiB
Go
740 lines
28 KiB
Go
package api
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
|
|
"rawmark/internal/auth"
|
|
"rawmark/internal/events"
|
|
"rawmark/internal/search"
|
|
"rawmark/internal/store"
|
|
"rawmark/internal/vault"
|
|
)
|
|
|
|
type fixture struct {
|
|
t *testing.T
|
|
url string
|
|
root string
|
|
store *store.Store
|
|
hub *events.Hub
|
|
// 已登录的客户端
|
|
admin *http.Client
|
|
}
|
|
|
|
func TestMoveBroadcastsWhenAssetRewriteReplacesFileIdentity(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.write("from.md", "")
|
|
if err := os.Mkdir(filepath.Join(f.root, "from.assets"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ch, cancel := f.hub.Subscribe()
|
|
defer cancel()
|
|
res := f.do(f.admin, "POST", "/api/v1/move", `{"from":"from.md","to":"to.md"}`, nil)
|
|
defer res.Body.Close()
|
|
if res.StatusCode != 200 {
|
|
t.Fatalf("移动失败: %d", res.StatusCode)
|
|
}
|
|
select {
|
|
case msg := <-ch:
|
|
var batch []events.Event
|
|
if err := json.Unmarshal(msg, &batch); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(batch) != 2 || batch[0].Type != "moved" || batch[0].From != "from.md" || batch[0].Path != "to.md" || batch[1].Type != "tree" {
|
|
t.Fatalf("移动事件不符: %s", msg)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("正文改写后未广播移动事件")
|
|
}
|
|
}
|
|
|
|
func TestMoveRollsBackWhenShareUpdateFails(t *testing.T) {
|
|
dir := t.TempDir()
|
|
v, err := vault.Open(filepath.Join(dir, "vault"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := v.Write("from.md", []byte("原文"), vault.Precondition{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := store.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := New(v, nil, st, nil, nil)
|
|
res := httptest.NewRecorder()
|
|
s.move(res, httptest.NewRequest("POST", "/api/v1/move", strings.NewReader(`{"from":"from.md","to":"to.md"}`)))
|
|
if res.Code != 500 {
|
|
t.Fatalf("数据库失败应返回错误: %d", res.Code)
|
|
}
|
|
if content, _, err := v.Read("from.md"); err != nil || string(content) != "原文" {
|
|
t.Fatalf("原笔记未恢复: %q %v", content, err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(v.Root(), "to.md")); !os.IsNotExist(err) {
|
|
t.Fatalf("失败后目标仍存在: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEventsRejectRevokedCredentials(t *testing.T) {
|
|
for _, kind := range []string{"session", "token"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
f := newFixture(t)
|
|
client := newClient()
|
|
headers := http.Header{}
|
|
var tokenID string
|
|
if kind == "session" {
|
|
f.do(client, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil).Body.Close()
|
|
} else {
|
|
created := decode[struct {
|
|
Token string
|
|
Info store.APIToken
|
|
}](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"events","scope":"read"}`, nil))
|
|
headers.Set("Authorization", "Bearer "+created.Token)
|
|
tokenID = created.Info.ID
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
conn, _, err := websocket.Dial(ctx, strings.Replace(f.url, "http://", "ws://", 1)+"/api/v1/events", &websocket.DialOptions{HTTPClient: client, HTTPHeader: headers})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.CloseNow()
|
|
// 首条事件确认订阅已经建立,避免把握手和广播的时序当作鉴权结果。
|
|
f.hub.Broadcast(events.Event{Type: "tree"})
|
|
if _, _, err := conn.Read(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if kind == "session" {
|
|
f.do(client, "POST", "/api/v1/auth/logout", "", nil).Body.Close()
|
|
} else {
|
|
f.do(f.admin, "DELETE", "/api/v1/tokens/"+tokenID, "", nil).Body.Close()
|
|
}
|
|
f.hub.Broadcast(events.Event{Type: "changed", Path: "private.md", Rev: "new"})
|
|
if _, _, err := conn.Read(ctx); websocket.CloseStatus(err) != websocket.StatusPolicyViolation {
|
|
t.Fatalf("撤销后应关闭连接,不能继续推送: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newFixture(t *testing.T) *fixture {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
v, err := vault.Open(filepath.Join(dir, "vault"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := store.Open(filepath.Join(dir, "rawmark.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
am := auth.New(st)
|
|
if err := am.Setup("password123"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
idx, err := search.Open(filepath.Join(dir, "index.db"), v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { idx.Close() })
|
|
hub := events.NewHub()
|
|
server := New(v, am, st, hub, idx)
|
|
server.ThemesDir = filepath.Join(dir, "themes")
|
|
server.PluginsDir = filepath.Join(dir, "plugins")
|
|
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
|
t.Cleanup(srv.Close)
|
|
f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, hub: hub, admin: newClient()}
|
|
if res := f.do(f.admin, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil); res.StatusCode != 200 {
|
|
t.Fatalf("登录失败: %d", res.StatusCode)
|
|
}
|
|
return f
|
|
}
|
|
|
|
func newClient() *http.Client {
|
|
jar, _ := cookiejar.New(nil)
|
|
return &http.Client{Jar: jar}
|
|
}
|
|
|
|
func (f *fixture) write(p, content string) {
|
|
abs := filepath.Join(f.root, p)
|
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func (f *fixture) do(c *http.Client, method, path, body string, headers map[string]string) *http.Response {
|
|
f.t.Helper()
|
|
req, err := http.NewRequest(method, f.url+path, strings.NewReader(body))
|
|
if err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
res, err := c.Do(req)
|
|
if err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
f.t.Cleanup(func() { res.Body.Close() })
|
|
return res
|
|
}
|
|
|
|
func (f *fixture) status(c *http.Client, method, path, body string, headers map[string]string) int {
|
|
f.t.Helper()
|
|
return f.do(c, method, path, body, headers).StatusCode
|
|
}
|
|
|
|
func decode[T any](t *testing.T, res *http.Response) T {
|
|
t.Helper()
|
|
var v T
|
|
if err := json.NewDecoder(res.Body).Decode(&v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
// 文档与实际接口一一对应:新增接口忘记写文档、文档中残留已删除的接口都会失败
|
|
func TestOpenAPICoversRoutes(t *testing.T) {
|
|
var spec struct {
|
|
Paths map[string]map[string]json.RawMessage
|
|
}
|
|
if err := json.Unmarshal(openAPISpec, &spec); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
documented := map[string]bool{}
|
|
for p, ops := range spec.Paths {
|
|
for method := range ops {
|
|
if method != "parameters" {
|
|
documented[strings.ToUpper(method)+" "+p] = true
|
|
}
|
|
}
|
|
}
|
|
wildcard := regexp.MustCompile(`\{(\w+)\.\.\.\}`)
|
|
s := &Server{}
|
|
for _, rt := range s.routes() {
|
|
key := strings.Replace(wildcard.ReplaceAllString(rt.pattern, "{$1}"), " /api/v1", " ", 1)
|
|
if !documented[key] {
|
|
t.Errorf("接口未写入 openapi.json: %s", rt.pattern)
|
|
}
|
|
delete(documented, key)
|
|
}
|
|
for key := range documented {
|
|
t.Errorf("openapi.json 中的接口不存在: %s", key)
|
|
}
|
|
}
|
|
|
|
func TestAPIResponsesAreNotCached(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.write("笔记.md", "# 正文")
|
|
// 登录状态与目录曾在 Firefox 离线时命中 HTTP 缓存,误进入无法加载的工作区。
|
|
for _, path := range []string{"/api/v1/auth/state", "/api/v1/tree", "/api/v1/files/笔记.md", "/api/v1/raw/笔记.md"} {
|
|
res := f.do(f.admin, "GET", path, "", nil)
|
|
if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != "no-store" {
|
|
t.Errorf("%s:状态 %d,缓存策略 %q", path, res.StatusCode, res.Header.Get("Cache-Control"))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCompressedManifestContentType(t *testing.T) {
|
|
body := strings.Repeat(" ", 300) + `{"name":"RawMark","display":"standalone"}`
|
|
handler := newStaticHandler(fstest.MapFS{"manifest.webmanifest": {Data: []byte(body)}})
|
|
req := httptest.NewRequest("GET", "/manifest.webmanifest", nil)
|
|
req.Header.Set("Accept-Encoding", "gzip")
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, req)
|
|
res := recorder.Result()
|
|
defer res.Body.Close()
|
|
if res.Header.Get("Content-Type") != "application/manifest+json" || res.Header.Get("Content-Encoding") != "gzip" {
|
|
t.Fatalf("清单响应头不正确:%v", res.Header)
|
|
}
|
|
zr, err := gzip.NewReader(res.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer zr.Close()
|
|
var manifest map[string]string
|
|
if err := json.NewDecoder(zr).Decode(&manifest); err != nil || manifest["name"] != "RawMark" {
|
|
t.Fatalf("压缩清单无法解析:%v", err)
|
|
}
|
|
}
|
|
|
|
func TestShareAccess(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.write("目录/笔记.md", "# 标题\n\n\n")
|
|
f.write("目录/笔记.assets/图.png", "PNG")
|
|
f.write("目录/其他.png", "OTHER")
|
|
f.write("目录/其他.assets/x.png", "X")
|
|
|
|
res := f.do(f.admin, "POST", "/api/v1/shares", `{"path":"目录/笔记.md","password":"访问密码","expiresIn":0}`, nil)
|
|
if res.StatusCode != 200 {
|
|
t.Fatalf("创建分享: %d", res.StatusCode)
|
|
}
|
|
sh := decode[shareView](t, res)
|
|
if !sh.HasPassword || !sh.Exists || sh.ExpiresAt != 0 || len(sh.Token) < 20 {
|
|
t.Fatalf("分享信息: %+v", sh)
|
|
}
|
|
base := "/api/v1/public/shares/" + sh.Token
|
|
guest := newClient()
|
|
if code := f.status(guest, "GET", base, "", nil); code != 401 {
|
|
t.Errorf("未输入密码应为 401,得到 %d", code)
|
|
}
|
|
if code := f.status(guest, "GET", base+"/assets/笔记.assets/图.png", "", nil); code != 401 {
|
|
t.Errorf("未输入密码读图片应为 401,得到 %d", code)
|
|
}
|
|
if code := f.status(guest, "POST", base+"/unlock", `{"password":"错的"}`, nil); code != 401 {
|
|
t.Errorf("错误密码应为 401,得到 %d", code)
|
|
}
|
|
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 {
|
|
t.Fatalf("正确密码: %d", code)
|
|
}
|
|
got := decode[map[string]any](t, f.do(guest, "GET", base, "", nil))
|
|
if got["title"] != "笔记" || got["content"] != "# 标题\n\n\n" || got["path"] != nil {
|
|
t.Errorf("分享内容: %v", got)
|
|
}
|
|
res = f.do(guest, "GET", base+"/assets/笔记.assets/图.png", "", nil)
|
|
if b, _ := io.ReadAll(res.Body); res.StatusCode != 200 || string(b) != "PNG" {
|
|
t.Errorf("本篇图片: %d %q", res.StatusCode, b)
|
|
}
|
|
for _, p := range []string{"其他.png", "笔记.assets/../其他.png", "其他.assets/x.png", "../目录/其他.png", "笔记.md"} {
|
|
if code := f.status(guest, "GET", base+"/assets/"+p, "", nil); code != 404 {
|
|
t.Errorf("图片目录之外的 %s 应为 404,得到 %d", p, code)
|
|
}
|
|
}
|
|
|
|
// 改名后分享跟随
|
|
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"目录","to":"新目录"}`, nil); code != 200 {
|
|
t.Fatalf("移动文件夹: %d", code)
|
|
}
|
|
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"新目录/笔记.md","to":"新目录/改名.md"}`, nil); code != 200 {
|
|
t.Fatalf("改名: %d", code)
|
|
}
|
|
got = decode[map[string]any](t, f.do(guest, "GET", base, "", nil))
|
|
if got["title"] != "改名" || !strings.Contains(got["content"].(string), "改名.assets/") {
|
|
t.Errorf("改名后分享内容: %v", got)
|
|
}
|
|
if code := f.status(guest, "GET", base+"/assets/改名.assets/图.png", "", nil); code != 200 {
|
|
t.Errorf("改名后图片: %d", code)
|
|
}
|
|
|
|
// 其他分享的凭据不能用于本分享
|
|
res = f.do(f.admin, "POST", "/api/v1/shares", `{"path":"新目录/其他.md"}`, nil)
|
|
if res.StatusCode != 404 {
|
|
t.Errorf("分享不存在的笔记应为 404,得到 %d", res.StatusCode)
|
|
}
|
|
f.write("新目录/其他.md", "其他")
|
|
other := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"新目录/其他.md","password":"另一个"}`, nil))
|
|
if code := f.status(guest, "GET", "/api/v1/public/shares/"+other.Token, "", nil); code != 401 {
|
|
t.Errorf("凭据只对对应分享有效,得到 %d", code)
|
|
}
|
|
|
|
// 列表、删除笔记、撤销
|
|
list := decode[struct{ Shares []shareView }](t, f.do(f.admin, "GET", "/api/v1/shares?path=新目录/改名.md", "", nil))
|
|
if len(list.Shares) != 1 || list.Shares[0].Path != "新目录/改名.md" {
|
|
t.Errorf("按笔记筛选: %+v", list.Shares)
|
|
}
|
|
if code := f.status(f.admin, "DELETE", "/api/v1/files/新目录/改名.md", "", nil); code != 200 {
|
|
t.Fatal(code)
|
|
}
|
|
list = decode[struct{ Shares []shareView }](t, f.do(f.admin, "GET", "/api/v1/shares", "", nil))
|
|
if len(list.Shares) != 2 || list.Shares[1].Exists {
|
|
t.Errorf("笔记删除后分享标记为不存在: %+v", list.Shares)
|
|
}
|
|
if code := f.status(guest, "GET", base, "", nil); code != 404 {
|
|
t.Errorf("笔记已删除应为 404,得到 %d", code)
|
|
}
|
|
if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+sh.Token, "", nil); code != 200 {
|
|
t.Fatal(code)
|
|
}
|
|
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 404 {
|
|
t.Errorf("撤销后应为 404,得到 %d", code)
|
|
}
|
|
if code := f.status(newClient(), "GET", "/api/v1/shares", "", nil); code != 401 {
|
|
t.Errorf("未登录不能管理分享,得到 %d", code)
|
|
}
|
|
}
|
|
|
|
func TestShareRandomToken(t *testing.T) {
|
|
f := newFixture(t)
|
|
name := "计划 #1? 50% +;\""
|
|
p := "目录/" + name + ".md"
|
|
f.write(p, "正文")
|
|
f.write("目录/"+name+".assets/图.png", "PNG")
|
|
body, err := json.Marshal(map[string]string{"path": p, "password": "访问密码"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil))
|
|
random, err := base64.RawURLEncoding.DecodeString(sh.Token)
|
|
if err != nil || len(random) != 16 {
|
|
t.Fatal("分享标识应为 128 位随机字符,不包含文件名称或目录")
|
|
}
|
|
other := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil))
|
|
if other.Token == sh.Token {
|
|
t.Fatal("同一笔记的分享链接不能重复")
|
|
}
|
|
base := "/api/v1/public/shares/" + url.PathEscape(sh.Token)
|
|
guest := newClient()
|
|
if code := f.status(guest, "GET", base, "", nil); code != 401 {
|
|
t.Fatalf("未解锁应拒绝访问,得到 %d", code)
|
|
}
|
|
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 {
|
|
t.Fatalf("解锁失败:%d", code)
|
|
}
|
|
if code := f.status(guest, "GET", base, "", nil); code != 200 {
|
|
t.Fatalf("带保留字符的链接应可读取正文,得到 %d", code)
|
|
}
|
|
asset := base + "/assets/" + url.PathEscape(name+".assets") + "/图.png"
|
|
if code := f.status(guest, "GET", asset, "", nil); code != 200 {
|
|
t.Fatalf("解锁凭据应覆盖图片路径,得到 %d", code)
|
|
}
|
|
if code := f.status(guest, "GET", "/api/v1/public/shares/"+url.PathEscape(other.Token), "", nil); code != 401 {
|
|
t.Fatalf("凭据不能解锁另一分享,得到 %d", code)
|
|
}
|
|
if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+url.PathEscape(sh.Token), "", nil); code != 200 {
|
|
t.Fatalf("撤销带保留字符的链接失败:%d", code)
|
|
}
|
|
if code := f.status(guest, "GET", base, "", nil); code != 404 {
|
|
t.Fatalf("撤销后应返回 404,得到 %d", code)
|
|
}
|
|
}
|
|
|
|
func TestFolderShareTracksItsCurrentContentsAndSubject(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.write("公开/子目录/文章.md", "第一版")
|
|
f.write("公开/子目录/文章.assets/图.png", "PNG")
|
|
f.write("公开/资料.txt", "资料")
|
|
f.write("公开/.私密.md", "不能公开")
|
|
f.write("公开/.git/config", "不能公开")
|
|
f.write("其他/私密.md", "不能公开")
|
|
if err := os.Symlink(filepath.Join(f.root, "其他"), filepath.Join(f.root, "公开", "别名")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
folder := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"公开","password":"访问密码"}`, nil))
|
|
note := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"公开/子目录/文章.md"}`, nil))
|
|
base := "/api/v1/public/shares/" + folder.Token
|
|
noteBase := "/api/v1/public/shares/" + note.Token
|
|
guest := newClient()
|
|
if code := f.status(guest, "GET", base+"?path=子目录/文章.md", "", nil); code != 401 {
|
|
t.Fatalf("目录内的文章也必须解锁,得到 %d", code)
|
|
}
|
|
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 {
|
|
t.Fatal(code)
|
|
}
|
|
type directory struct {
|
|
Title string
|
|
Dir bool
|
|
Entries []vault.Entry
|
|
}
|
|
listed := decode[directory](t, f.do(guest, "GET", base, "", nil))
|
|
if listed.Title != "公开" || !listed.Dir || len(listed.Entries) != 5 {
|
|
t.Fatalf("应递归列出全部普通文件和子目录: %+v", listed)
|
|
}
|
|
for _, e := range listed.Entries {
|
|
if strings.Contains(e.Path, "公开/") || strings.Contains(e.Path, "私密") || strings.Contains(e.Path, "别名") || strings.Contains(e.Path, ".git") {
|
|
t.Fatalf("目录列表越界: %s", e.Path)
|
|
}
|
|
}
|
|
f.write("公开/子目录/文章.md", "最新正文")
|
|
article := decode[map[string]any](t, f.do(guest, "GET", base+"?path=子目录/文章.md", "", nil))
|
|
if article["content"] != "最新正文" || article["path"] != nil {
|
|
t.Fatalf("应提供当前正文且不暴露仓库路径: %v", article)
|
|
}
|
|
for _, rel := range []string{"../其他/私密.md", ".私密.md", ".git/config", "别名/私密.md"} {
|
|
if code := f.status(guest, "GET", base+"?path="+url.QueryEscape(rel), "", nil); code != 404 {
|
|
t.Fatalf("不允许读取 %s,得到 %d", rel, code)
|
|
}
|
|
if code := f.status(guest, "GET", base+"/assets/"+url.PathEscape(rel), "", nil); code != 404 {
|
|
t.Fatalf("资源接口不允许读取 %s,得到 %d", rel, code)
|
|
}
|
|
}
|
|
if code := f.status(guest, "GET", base+"/assets/子目录/文章.assets/图.png", "", nil); code != 200 {
|
|
t.Fatalf("目录中的图片不可读: %d", code)
|
|
}
|
|
res := f.do(guest, "GET", base+"/assets/资料.txt?download=1", "", nil)
|
|
content, err := io.ReadAll(res.Body)
|
|
if err != nil || res.StatusCode != 200 || string(content) != "资料" || !strings.HasPrefix(res.Header.Get("Content-Disposition"), "attachment;") {
|
|
t.Fatalf("普通文件下载失败: %d %v", res.StatusCode, err)
|
|
}
|
|
f.write("公开/新增.md", "新内容")
|
|
listed = decode[directory](t, f.do(guest, "GET", base, "", nil))
|
|
if len(listed.Entries) != 6 {
|
|
t.Fatal("新增文件应自动进入目录分享")
|
|
}
|
|
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"公开","to":"移动后的目录"}`, nil); code != 200 {
|
|
t.Fatal(code)
|
|
}
|
|
listed = decode[directory](t, f.do(guest, "GET", base, "", nil))
|
|
if listed.Title != "移动后的目录" {
|
|
t.Fatal("目录主体移动后原分享应跟随")
|
|
}
|
|
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"移动后的目录/子目录/文章.md","to":"其他/移出.md"}`, nil); code != 200 {
|
|
t.Fatal(code)
|
|
}
|
|
if code := f.status(guest, "GET", base+"?path=子目录/文章.md", "", nil); code != 404 {
|
|
t.Fatalf("移出的文章不能继续通过目录分享读取: %d", code)
|
|
}
|
|
article = decode[map[string]any](t, f.do(guest, "GET", noteBase, "", nil))
|
|
if article["content"] != "最新正文" || article["title"] != "移出" {
|
|
t.Fatal("单篇分享应跟随文章移出目录")
|
|
}
|
|
file := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"移动后的目录/资料.txt"}`, nil))
|
|
if code := f.status(guest, "GET", "/api/v1/public/shares/"+file.Token+"/assets/", "", nil); code != 200 {
|
|
t.Fatalf("应能分享普通文件: %d", code)
|
|
}
|
|
if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+folder.Token, "", nil); code != 200 {
|
|
t.Fatal(code)
|
|
}
|
|
if code := f.status(guest, "GET", base+"/assets/资料.txt", "", nil); code != 404 {
|
|
t.Fatalf("撤销后资源也必须失效: %d", code)
|
|
}
|
|
}
|
|
|
|
func TestShareWithoutPasswordAndExpiry(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.write("a.md", "公开")
|
|
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"a.md","expiresIn":3600}`, nil))
|
|
if sh.HasPassword || sh.ExpiresAt-time.Now().UnixMilli() < 3590_000 {
|
|
t.Fatalf("分享信息: %+v", sh)
|
|
}
|
|
if code := f.status(newClient(), "GET", "/api/v1/public/shares/"+sh.Token, "", nil); code != 200 {
|
|
t.Errorf("无密码分享直接可读,得到 %d", code)
|
|
}
|
|
if err := f.store.CreateShare(store.Share{Token: "expired", Path: "a.md", CreatedAt: 1, ExpiresAt: time.Now().Add(-time.Second).UnixMilli()}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if code := f.status(newClient(), "GET", "/api/v1/public/shares/expired", "", nil); code != 404 {
|
|
t.Errorf("过期分享应为 404,得到 %d", code)
|
|
}
|
|
for _, body := range []string{`{"path":"../a.md"}`, `{"path":"a.md","expiresIn":-1}`} {
|
|
if code := f.status(f.admin, "POST", "/api/v1/shares", body, nil); code != 400 {
|
|
t.Errorf("%s 应为 400,得到 %d", body, code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPITokenScopes(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.write("a.md", "内容")
|
|
create := func(scope string) string {
|
|
res := f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"脚本","scope":"`+scope+`"}`, nil)
|
|
if res.StatusCode != 200 {
|
|
t.Fatalf("创建 %s 令牌: %d", scope, res.StatusCode)
|
|
}
|
|
return decode[struct{ Token string }](t, res).Token
|
|
}
|
|
read, write := create("read"), create("write")
|
|
if code := f.status(f.admin, "POST", "/api/v1/tokens", `{"name":"x","scope":"admin"}`, nil); code != 400 {
|
|
t.Errorf("无效范围应为 400,得到 %d", code)
|
|
}
|
|
bearer := func(tok string) map[string]string { return map[string]string{"Authorization": "Bearer " + tok} }
|
|
anon := &http.Client{}
|
|
cases := []struct {
|
|
name, token, method, path, body string
|
|
want int
|
|
}{
|
|
{"只读令牌可读", read, "GET", "/api/v1/files/a.md", "", 200},
|
|
{"只读令牌可搜索", read, "GET", "/api/v1/tree", "", 200},
|
|
{"只读令牌不能写", read, "PUT", "/api/v1/files/a.md", "改", 403},
|
|
{"读写令牌可写", write, "PUT", "/api/v1/files/b.md", "新", 200},
|
|
{"读写令牌也不能管理令牌", write, "GET", "/api/v1/tokens", "", 403},
|
|
{"令牌不能管理分享", write, "POST", "/api/v1/shares", `{"path":"a.md"}`, 403},
|
|
{"无效令牌", "rm_wrong", "GET", "/api/v1/tree", "", 401},
|
|
{"格式不符的令牌", "abc", "GET", "/api/v1/tree", "", 401},
|
|
}
|
|
for _, c := range cases {
|
|
if code := f.status(anon, c.method, c.path, c.body, bearer(c.token)); code != c.want {
|
|
t.Errorf("%s: %d,期望 %d", c.name, code, c.want)
|
|
}
|
|
}
|
|
|
|
list := decode[struct{ Tokens []store.APIToken }](t, f.do(f.admin, "GET", "/api/v1/tokens", "", nil))
|
|
if len(list.Tokens) != 2 || list.Tokens[0].LastUsedAt == 0 {
|
|
t.Fatalf("令牌列表(应记录使用时间): %+v", list.Tokens)
|
|
}
|
|
raw, _ := io.ReadAll(f.do(f.admin, "GET", "/api/v1/tokens", "", nil).Body)
|
|
if strings.Contains(string(raw), read) || strings.Contains(string(raw), "hash") {
|
|
t.Errorf("列表不应包含令牌明文或哈希: %s", raw)
|
|
}
|
|
for _, tk := range list.Tokens {
|
|
if code := f.status(f.admin, "DELETE", "/api/v1/tokens/"+tk.ID, "", nil); code != 200 {
|
|
t.Fatal(code)
|
|
}
|
|
}
|
|
if code := f.status(anon, "GET", "/api/v1/tree", "", bearer(read)); code != 401 {
|
|
t.Errorf("吊销后应为 401,得到 %d", code)
|
|
}
|
|
}
|
|
|
|
func TestSettingsMerge(t *testing.T) {
|
|
f := newFixture(t)
|
|
if got := decode[map[string]any](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil)); len(got) != 0 {
|
|
t.Fatalf("初始为空对象: %v", got)
|
|
}
|
|
f.do(f.admin, "PATCH", "/api/v1/settings", `{"fontSize":18,"theme":"dark"}`, nil)
|
|
got := decode[map[string]any](t, f.do(f.admin, "PATCH", "/api/v1/settings", `{"fontSize":20,"theme":null,"cssSnippets":[{"id":"a"}]}`, nil))
|
|
if got["fontSize"] != 20.0 || got["theme"] != nil || len(got["cssSnippets"].([]any)) != 1 {
|
|
t.Fatalf("合并结果: %v", got)
|
|
}
|
|
if again := decode[map[string]any](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil)); again["fontSize"] != 20.0 || len(again) != 2 {
|
|
t.Fatalf("读取: %v", again)
|
|
}
|
|
for _, body := range []string{`[1]`, `null`, `"x"`, `{`} {
|
|
if code := f.status(f.admin, "PATCH", "/api/v1/settings", body, nil); code != 400 {
|
|
t.Errorf("%s 应为 400,得到 %d", body, code)
|
|
}
|
|
}
|
|
tok := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"x","scope":"write"}`, nil)).Token
|
|
bearer := map[string]string{"Authorization": "Bearer " + tok}
|
|
if code := f.status(&http.Client{}, "GET", "/api/v1/settings", "", bearer); code != 200 {
|
|
t.Errorf("令牌可读设置,得到 %d", code)
|
|
}
|
|
if code := f.status(&http.Client{}, "PATCH", "/api/v1/settings", `{"a":1}`, bearer); code != 403 {
|
|
t.Errorf("令牌不能改设置(自定义 CSS 等会影响页面),得到 %d", code)
|
|
}
|
|
}
|
|
|
|
func TestChangePassword(t *testing.T) {
|
|
f := newFixture(t)
|
|
other := newClient()
|
|
f.do(other, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil)
|
|
if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"wrong","next":"new-password"}`, nil); code != 401 {
|
|
t.Errorf("当前密码错误应为 401,得到 %d", code)
|
|
}
|
|
if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"password123","next":"short"}`, nil); code != 400 {
|
|
t.Errorf("新密码过短应为 400,得到 %d", code)
|
|
}
|
|
if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"password123","next":"new-password"}`, nil); code != 200 {
|
|
t.Fatalf("修改密码: %d", code)
|
|
}
|
|
if code := f.status(f.admin, "GET", "/api/v1/tree", "", nil); code != 200 {
|
|
t.Errorf("当前会话应保留,得到 %d", code)
|
|
}
|
|
if code := f.status(other, "GET", "/api/v1/tree", "", nil); code != 401 {
|
|
t.Errorf("其他会话应失效,得到 %d", code)
|
|
}
|
|
if code := f.status(newClient(), "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil); code != 401 {
|
|
t.Errorf("旧密码应失效,得到 %d", code)
|
|
}
|
|
if code := f.status(newClient(), "POST", "/api/v1/auth/login", `{"password":"new-password"}`, nil); code != 200 {
|
|
t.Errorf("新密码登录,得到 %d", code)
|
|
}
|
|
}
|
|
|
|
func TestSetupWithoutCode(t *testing.T) {
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "setup.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
server := New(nil, auth.New(st), st, nil, nil)
|
|
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
|
t.Cleanup(srv.Close)
|
|
f := &fixture{t: t, url: srv.URL}
|
|
owner := newClient()
|
|
before := decode[struct{ SetUp, Authenticated bool }](t, f.do(owner, "GET", "/api/v1/auth/state", "", nil))
|
|
if before.SetUp || before.Authenticated {
|
|
t.Fatal("新实例不应已初始化或登录")
|
|
}
|
|
if status := f.status(owner, "POST", "/api/v1/auth/setup", `{"password":"short"}`, nil); status != 400 {
|
|
t.Fatalf("弱密码应被拒绝: %d", status)
|
|
}
|
|
if status := f.status(owner, "POST", "/api/v1/auth/setup", `{"password":"password123"}`, nil); status != 200 {
|
|
t.Fatalf("无需启动码即可初始化: %d", status)
|
|
}
|
|
after := decode[struct{ SetUp, Authenticated bool }](t, f.do(owner, "GET", "/api/v1/auth/state", "", nil))
|
|
if !after.SetUp || !after.Authenticated {
|
|
t.Fatal("初始化后应自动登录")
|
|
}
|
|
if status := f.status(newClient(), "POST", "/api/v1/auth/setup", `{"password":"replacement"}`, nil); status != 409 {
|
|
t.Fatalf("重复初始化必须拒绝: %d", status)
|
|
}
|
|
// 新建 Manager 模拟重启,不能重新开放密码设置。
|
|
reopened := auth.New(st)
|
|
if err := reopened.Setup("replacement"); !errors.Is(err, auth.ErrAlreadySetUp) {
|
|
t.Fatalf("重启后不能覆盖密码: %v", err)
|
|
}
|
|
if _, err := reopened.Login("test", "password123"); err != nil {
|
|
t.Fatal("原密码应仍然有效", err)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentSetupCannotOverwritePassword(t *testing.T) {
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "setup.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
manager := auth.New(st)
|
|
server := New(nil, manager, st, nil, nil)
|
|
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
|
t.Cleanup(srv.Close)
|
|
passwords := []string{"password-one", "password-two"}
|
|
type outcome struct {
|
|
index, status int
|
|
err error
|
|
}
|
|
results := make(chan outcome, 2)
|
|
for index, password := range passwords {
|
|
go func() {
|
|
res, err := newClient().Post(srv.URL+"/api/v1/auth/setup", "application/json", strings.NewReader(`{"password":"`+password+`"}`))
|
|
if err != nil {
|
|
results <- outcome{index: index, err: err}
|
|
return
|
|
}
|
|
res.Body.Close()
|
|
results <- outcome{index: index, status: res.StatusCode}
|
|
}()
|
|
}
|
|
winner, conflicts := -1, 0
|
|
for range passwords {
|
|
result := <-results
|
|
if result.err != nil {
|
|
t.Fatal(result.err)
|
|
}
|
|
switch result.status {
|
|
case 200:
|
|
if winner >= 0 {
|
|
t.Fatal("初始化成功不止一次")
|
|
}
|
|
winner = result.index
|
|
case 409:
|
|
conflicts++
|
|
default:
|
|
t.Fatalf("初始化状态不符: %d", result.status)
|
|
}
|
|
}
|
|
if winner < 0 || conflicts != 1 {
|
|
t.Fatal("必须只有一个初始化请求成功")
|
|
}
|
|
if _, err := manager.Login("test", passwords[winner]); err != nil {
|
|
t.Fatal("成功请求的密码未保存", err)
|
|
}
|
|
if _, err := manager.Login("test", passwords[1-winner]); !errors.Is(err, auth.ErrBadCredentials) {
|
|
t.Fatal("失败请求不应修改密码", err)
|
|
}
|
|
}
|