Files
eezd 5514a1e465
Development CI / check-and-package (push) Successful in 4m18s
GitHub Git mirror / mirror (push) Successful in 28s
Development CI / image (push) Successful in 21s
Development CI / deploy-development (push) Successful in 15s
feat: synchronize pinned tabs across devices
2026-11-04 18:20:16 +08:00

552 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"compress/gzip"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"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 {
server *Server
t *testing.T
url string
root string
store *store.Store
hub *events.Hub
// Authenticated client.
admin *http.Client
}
func TestNoteRevisionSurvivesProxyBoundary(t *testing.T) {
f := newFixture(t)
f.write("proxy.md", "original")
res := f.do(f.admin, "GET", "/api/v1/files/proxy.md", "", nil)
defer res.Body.Close()
etag := res.Header.Get("ETag")
if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != "no-store, no-transform" || !strings.HasPrefix(etag, `"`) {
t.Fatalf("笔记响应必须保留强版本标识并禁止代理转换: %d %v", res.StatusCode, res.Header)
}
if code := f.status(f.admin, "PUT", "/api/v1/files/proxy.md", "updated", map[string]string{"If-Match": etag}); code != http.StatusOK {
t.Fatalf("读取的版本标识应允许条件保存: %d", code)
}
if code := f.status(f.admin, "PUT", "/api/v1/files/proxy.md", "stale", map[string]string{"If-Match": etag}); code != http.StatusPreconditionFailed {
t.Fatalf("过期版本必须仍拒绝保存: %d", code)
}
}
func TestMoveBroadcastsWhenAssetRewriteReplacesFileIdentity(t *testing.T) {
f := newFixture(t)
f.write("from.md", "![](from.assets/x.png)")
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 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.login(client, "password123").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()
// The first event confirms subscription before testing authorization, avoiding handshake/broadcast timing races.
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.DataDir = dir
server.ThemesDir = filepath.Join(dir, "themes")
server.PluginsDir = filepath.Join(dir, "plugins")
server.FontsDir = filepath.Join(dir, "fonts")
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(), server: server}
if res := f.login(f.admin, "password123"); res.StatusCode != 200 {
t.Fatalf("登录失败: %d", res.StatusCode)
}
return f
}
type bearerTransport struct {
mu sync.Mutex
token string
}
func (b *bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
r = r.Clone(r.Context())
b.mu.Lock()
token := b.token
b.mu.Unlock()
if token != "" && r.Header.Get("Authorization") == "" {
r.Header.Set("Authorization", "Bearer "+token)
}
return http.DefaultTransport.RoundTrip(r)
}
func newClient() *http.Client { return &http.Client{Transport: &bearerTransport{}} }
func setClientToken(c *http.Client, token string) {
b := c.Transport.(*bearerTransport)
b.mu.Lock()
b.token = token
b.mu.Unlock()
}
func (f *fixture) login(c *http.Client, password string) *http.Response {
challenge := decode[struct {
CaptchaID string `json:"captchaId"`
}](f.t, f.do(c, "POST", "/api/v1/auth/captcha", "{}", nil))
f.server.captchas.mu.Lock()
digits := f.server.captchas.challenges[challenge.CaptchaID].digits
f.server.captchas.mu.Unlock()
code := make([]byte, len(digits))
for i, digit := range digits {
code[i] = digit + '0'
}
body, _ := json.Marshal(map[string]string{"password": password, "captchaId": challenge.CaptchaID, "captchaCode": string(code)})
res := f.do(c, "POST", "/api/v1/auth/login", string(body), nil)
if res.StatusCode == 200 {
content, _ := io.ReadAll(res.Body)
res.Body.Close()
res.Body = io.NopCloser(strings.NewReader(string(content)))
var result struct{ Data struct{ Token string } }
if err := json.Unmarshal(content, &result); err != nil {
f.t.Fatal(err)
}
setClientToken(c, result.Data.Token)
}
return res
}
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 envelope struct {
Code int
Data T
Message string
}
if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {
t.Fatal(err)
}
if envelope.Code != 0 {
t.Fatalf("业务请求失败:HTTP %d,code=%d,message=%s", res.StatusCode, envelope.Code, envelope.Message)
}
return envelope.Data
}
// Documentation must match actual endpoints: missing new routes and obsolete documented routes both fail.
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 once cached identity and tree responses offline, incorrectly opening an unloadable workspace.
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)
want := "no-store"
if strings.HasPrefix(path, "/api/v1/files/") {
want = "no-store, no-transform"
}
if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != want {
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 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},
{"无效令牌", "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 TestPinnedTabsSharedAcrossSessions(t *testing.T) {
f := newFixture(t)
other := newClient()
f.login(other, "password123")
f.do(f.admin, "PATCH", "/api/v1/settings", `{"pinnedTabs":["a.md","目录/b.md"]}`, nil).Body.Close()
got := decode[struct{ PinnedTabs []string }](t, f.do(other, "GET", "/api/v1/settings", "", nil))
if len(got.PinnedTabs) != 2 || got.PinnedTabs[0] != "a.md" || got.PinnedTabs[1] != "目录/b.md" {
t.Fatalf("另一会话未读取到固定标签: %v", got.PinnedTabs)
}
f.do(other, "PATCH", "/api/v1/settings", `{"pinnedTabs":[]}`, nil).Body.Close()
again := decode[struct{ PinnedTabs []string }](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil))
if again.PinnedTabs == nil || len(again.PinnedTabs) != 0 {
t.Fatalf("取消固定必须保存为空列表: %v", again.PinnedTabs)
}
stored, ok, err := f.store.Setting(settingsKey)
if err != nil || !ok || !strings.Contains(stored, `"pinnedTabs":[]`) {
t.Fatalf("固定标签未持久化到数据库: %q, %v", stored, err)
}
}
func TestChangePassword(t *testing.T) {
f := newFixture(t)
other := newClient()
f.login(other, "password123")
if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"wrong","next":"new-password"}`, nil); code != 400 {
t.Errorf("当前密码错误应为 400,得到 %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.login(newClient(), "password123").StatusCode; code != 401 {
t.Errorf("旧密码应失效,得到 %d", code)
}
if code := f.login(newClient(), "new-password").StatusCode; 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, server: server}
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)
}
setup := f.do(owner, "POST", "/api/v1/auth/setup", `{"password":"password123"}`, nil)
if status := setup.StatusCode; status != 200 {
t.Fatalf("无需启动码即可初始化: %d", status)
}
setClientToken(owner, decode[struct{ Token string }](t, setup).Token)
if res := f.do(owner, "GET", "/api/v1/auth/me", "", nil); res.StatusCode != 200 {
t.Fatal("初始化未签发有效会话")
}
after := decode[struct{ SetUp, Authenticated bool }](t, f.do(owner, "GET", "/api/v1/auth/state", "", nil))
if !after.SetUp {
t.Fatal("初始化后应自动登录")
}
if status := f.status(newClient(), "POST", "/api/v1/auth/setup", `{"password":"replacement"}`, nil); status != 409 {
t.Fatalf("重复初始化必须拒绝: %d", status)
}
// A new Manager simulates a restart and must not reopen password setup.
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)
}
}