215 lines
7.4 KiB
Go
215 lines
7.4 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"rawmark/internal/search"
|
|
"rawmark/internal/vault"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"rawmark/internal/i18n"
|
|
)
|
|
|
|
func localizedResponse(t *testing.T, res *http.Response) response {
|
|
t.Helper()
|
|
defer res.Body.Close()
|
|
var body response
|
|
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return body
|
|
}
|
|
func TestLocalizedHTTPContracts(t *testing.T) {
|
|
f := newFixture(t)
|
|
for _, language := range []string{"en", "zh-CN"} {
|
|
h := map[string]string{"Accept-Language": language}
|
|
res := f.do(newClient(), "GET", "/api/v1/settings", "", h)
|
|
if res.StatusCode != 401 || res.Header.Get("Content-Language") != language || res.Header.Get("Cache-Control") != "no-store" {
|
|
t.Fatal("authentication response contract changed")
|
|
}
|
|
body := localizedResponse(t, res)
|
|
want := "Please sign in"
|
|
if language == "zh-CN" {
|
|
want = "请先登录"
|
|
}
|
|
if body.Message != want || body.Code != 40100 {
|
|
t.Fatalf("%s: %+v", language, body)
|
|
}
|
|
res = f.do(f.admin, "PATCH", "/api/v1/settings", `{"locale":"fr"}`, h)
|
|
if res.StatusCode != 400 {
|
|
t.Fatal("invalid locale accepted")
|
|
}
|
|
if body := localizedResponse(t, res); body.Code != 40000 || body.Message != i18n.Text(i18n.Locale(language), "invalid_locale") {
|
|
t.Fatal(body)
|
|
}
|
|
id, _ := challengeFor(t, f)
|
|
res = f.do(newClient(), "POST", "/api/v1/auth/login", loginBody(id, "invalid", "password123"), h)
|
|
if res.StatusCode != 400 {
|
|
t.Fatal("invalid captcha status changed")
|
|
}
|
|
if body := localizedResponse(t, res); body.Code != CodeCaptchaInvalid || !strings.Contains(body.Message, map[string]string{"en": "Refresh", "zh-CN": "刷新"}[language]) {
|
|
t.Fatal(body)
|
|
}
|
|
id, code := challengeFor(t, f)
|
|
f.server.captchas.mu.Lock()
|
|
challenge := f.server.captchas.challenges[id]
|
|
challenge.expires = time.Now().Add(-time.Second)
|
|
f.server.captchas.challenges[id] = challenge
|
|
f.server.captchas.mu.Unlock()
|
|
res = f.do(newClient(), "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), h)
|
|
if body := localizedResponse(t, res); body.Code != CodeCaptchaExpired {
|
|
t.Fatal(body)
|
|
}
|
|
}
|
|
for _, value := range []string{`{"locale":"zh-CN"}`, `{"locale":"en"}`, `{"locale":null}`} {
|
|
res := f.do(f.admin, "PATCH", "/api/v1/settings", value, nil)
|
|
if res.StatusCode != 200 {
|
|
t.Fatal("valid language rejected")
|
|
}
|
|
res.Body.Close()
|
|
}
|
|
if err := validatePreferences(map[string]json.RawMessage{settingsKey: json.RawMessage(`{"locale":"fr"}`)}); !errors.Is(err, errMaintenanceInput) {
|
|
t.Fatal("invalid imported language accepted")
|
|
}
|
|
}
|
|
func TestMaintenanceStatusIsRenderedForEachReader(t *testing.T) {
|
|
f := newFixture(t)
|
|
if err := f.server.maintain("export_backup_e9d3dd", func() error { return i18n.New("invalid_locale") }); err == nil {
|
|
t.Fatal("missing failure")
|
|
}
|
|
for _, language := range []string{"en", "zh-CN"} {
|
|
data := decode[maintenanceStatus](t, f.do(f.admin, "GET", "/api/v1/maintenance/status", "", map[string]string{"Accept-Language": language}))
|
|
if data.Operation != i18n.Text(i18n.Locale(language), "export_backup_e9d3dd") || data.Error != i18n.Text(i18n.Locale(language), "invalid_locale") {
|
|
t.Fatal(data)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLocalizedResultDetailsDoNotRewriteSourceData(t *testing.T) {
|
|
f := newFixture(t)
|
|
const content = "---\ntitle: User title\ntags: 1\n---\nUser content\n"
|
|
f.write("bad.md", content)
|
|
f.write("used.md", "")
|
|
f.write("assets/used.png", "synthetic image")
|
|
if err := f.server.index.Rebuild(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, language := range []string{"en", "zh-CN"} {
|
|
h := map[string]string{"Accept-Language": language}
|
|
result := decode[struct {
|
|
Notes []struct {
|
|
Path string
|
|
Warnings []string
|
|
}
|
|
}](t, f.do(f.admin, "GET", "/api/v1/metadata", "", h))
|
|
found := false
|
|
for _, note := range result.Notes {
|
|
if note.Path == "bad.md" {
|
|
found = true
|
|
if len(note.Warnings) != 1 || note.Warnings[0] != i18n.Text(i18n.Locale(language), "metadata_tags") {
|
|
t.Fatal(note.Warnings)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("note missing")
|
|
}
|
|
// Metadata warnings still block destructive cleanup, in the requested language.
|
|
res := f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/used.png"]}`, h)
|
|
body := localizedResponse(t, res)
|
|
if res.StatusCode != 400 || !strings.Contains(body.Message, i18n.Text(i18n.Locale(language), "metadata_tags")) {
|
|
t.Fatal(body)
|
|
}
|
|
}
|
|
got, _, err := f.server.vault.Read("bad.md")
|
|
if err != nil || string(got) != content {
|
|
t.Fatal("localization changed note contents")
|
|
}
|
|
if _, err := f.server.vault.Delete("bad.md"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, language := range []string{"en", "zh-CN"} {
|
|
result := decode[vault.CleanupResult](t, f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/used.png"]}`, map[string]string{"Accept-Language": language}))
|
|
if len(result.Skipped) != 1 || result.Skipped[0].Reason != i18n.Text(i18n.Locale(language), "image_in_use") {
|
|
t.Fatal(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLocalizedMetadataSkipsDeletedOrMovedWarningNotes(t *testing.T) {
|
|
for _, operation := range []string{"delete", "move"} {
|
|
t.Run(operation, func(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.write("bad.md", "---\ntags: 1\n---\nOld\n")
|
|
f.write("good.md", "Good\n")
|
|
if err := f.server.index.Rebuild(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var err error
|
|
if operation == "delete" {
|
|
_, err = f.server.vault.Delete("bad.md")
|
|
} else {
|
|
_, err = f.server.vault.Move("bad.md", "moved.md")
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, language := range []string{"en", "zh-CN"} {
|
|
res := f.do(f.admin, "GET", "/api/v1/metadata", "", map[string]string{"Accept-Language": language})
|
|
if res.StatusCode != http.StatusOK {
|
|
t.Fatalf("stale warning note invalidated metadata: HTTP %d", res.StatusCode)
|
|
}
|
|
result := decode[struct{ Notes []search.NoteMetadata }](t, res)
|
|
if len(result.Notes) != 1 || result.Notes[0].Path != "good.md" {
|
|
t.Fatalf("unexpected surviving metadata: %+v", result.Notes)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLocalizedMetadataKeepsReanalyzedNoteConsistent(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.write("bad.md", "---\ntags: 1\n---\nOld\n")
|
|
if err := f.server.index.Rebuild(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const latest = "---\ntitle: Updated title\ntags: [new]\n---\n\n[Note](./other.md)\n"
|
|
f.write("bad.md", latest)
|
|
for _, language := range []string{"en", "zh-CN"} {
|
|
result := decode[struct{ Notes []search.NoteMetadata }](t, f.do(f.admin, "GET", "/api/v1/metadata", "", map[string]string{"Accept-Language": language}))
|
|
if len(result.Notes) != 1 {
|
|
t.Fatal(result)
|
|
}
|
|
note := result.Notes[0]
|
|
if note.Rev != vault.Rev([]byte(latest)) || !reflect.DeepEqual(note.Metadata, vault.Analyze("bad.md", []byte(latest))) {
|
|
t.Fatalf("metadata mixes file revisions: %+v", note)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLocalizedMetadataPreservesReadErrors(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.write("bad.md", "---\ntags: 1\n---\nOld\n")
|
|
if err := f.server.index.Rebuild(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file := filepath.Join(f.root, "bad.md")
|
|
if err := os.Remove(file); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(file, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res := f.do(f.admin, "GET", "/api/v1/metadata", "", nil); res.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("read failure was hidden: HTTP %d", res.StatusCode)
|
|
}
|
|
}
|