Files
beardrive/internal/webapp/restore_test.go
T
e19fec0534 feat(webapp): old URLs follow a moved file, and share links follow the file (BEA-81) (#130)
There is no rename in beardrive: the scanner emits a put at the new path and
a delete at the old, same device, same blob, one cycle. Everything keyed on a
path therefore broke the moment a file moved — the viewer 404'd, history lost
the file's own past versions, restore refused them, and a share link either
404'd or silently served whatever unrelated file later took its address.

internal/webapp/moves.go derives the pairing from the ops the replay already
walks, cached with the snapshot. Deliberately not a rename op: journal.Less
and Replay are what every device converges to, and every already-shipped
journal would still need the heuristic to read its own history.

The two rules point in opposite directions on purpose. A viewer URL is an
address, so a LIVE path always wins and only an empty one redirects. A share
token is a promise about one file, so it follows the file even when a new one
takes the old address — and 404s forever once the file is deleted.

Nothing here writes an op or touches sync.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:52:20 +09:00

347 lines
12 KiB
Go
Raw 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 webapp
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/journal"
)
// journalsAt reads every journal file under a project's storage prefix, so a
// test can prove a write touched only the server's own.
func journalsAt(t *testing.T, dir string) map[string]string {
t.Helper()
entries, err := os.ReadDir(filepath.Join(dir, "journal"))
if err != nil {
t.Fatal(err)
}
out := map[string]string{}
for _, e := range entries {
b, err := os.ReadFile(filepath.Join(dir, "journal", e.Name()))
if err != nil {
t.Fatal(err)
}
out[e.Name()] = string(b)
}
return out
}
func historyOf(t *testing.T, h http.Handler, base, path string) []HistoryEntry {
t.Helper()
rec := do(t, h, "GET", base+"history?path="+path, nil)
var out struct {
Entries []HistoryEntry `json:"entries"`
}
mustJSON(t, rec, &out)
return out.Entries
}
// Restoring appends a new put pointing at the old blob: the file serves the
// historical content again, and the restore itself is visible in history.
func TestRestoreWritesNewOp(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "notes/plan.md", "v1")
f.put("dev1", "notes/plan.md", "v2 longer")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "notes/plan.md", "sha": shaOf("v1")})
var out struct {
OK bool `json:"ok"`
Blob string `json:"blob"`
Size int64 `json:"size"`
}
mustJSON(t, rec, &out)
if !out.OK || out.Blob != shaOf("v1") || out.Size != int64(len("v1")) {
t.Fatalf("restore response = %+v", out)
}
if rec := do(t, h, "GET", base+"file?path=notes/plan.md", nil); rec.Body.String() != "v1" {
t.Fatalf("file after restore = %q, want v1", rec.Body)
}
entries := historyOf(t, h, base, "notes/plan.md")
if len(entries) != 3 {
t.Fatalf("history = %d entries, want 3", len(entries))
}
newest := entries[0]
want := "restore notes/plan.md@" + shaOf("v1")[:8]
if newest.Note != want {
t.Fatalf("restore note = %q, want %q", newest.Note, want)
}
if newest.Blob != shaOf("v1") || newest.Size != int64(len("v1")) {
t.Fatalf("restore entry = %+v", newest)
}
}
// A blob that exists but was never a version of THIS path must not be
// pasteable onto it.
func TestRestoreUnknownVersion(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "a.md", "mine")
f.put("dev1", "b.md", "someone else's")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
for _, sha := range []string{shaOf("b.md content that is elsewhere"), shaOf("someone else's")} {
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "a.md", "sha": sha})
if rec.Code != http.StatusNotFound {
t.Fatalf("restore of a foreign blob: %d %s, want 404", rec.Code, rec.Body)
}
}
// a malformed sha never reaches the journals either
if rec := do(t, h, "POST", base+"restore", map[string]string{"path": "a.md", "sha": "nope"}); rec.Code != http.StatusBadRequest {
t.Fatalf("bad sha: %d, want 400", rec.Code)
}
if got := journalsAt(t, dir); len(got) != len(before) {
t.Fatalf("a refused restore wrote a journal: %v → %v", before, got)
}
for name, data := range before {
if got := journalsAt(t, dir)[name]; got != data {
t.Fatalf("journal %s changed on a refused restore", name)
}
}
}
// Restoring the version the file ALREADY has is not a change — it would
// journal a +0 0 row and sync it to every device. The guard is narrow: the
// version below it still restores.
func TestRestoreNoOpCurrentVersion(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "f.md", "v1")
f.put("dev1", "f.md", "v2")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "f.md", "sha": shaOf("v2")})
if rec.Code != http.StatusConflict {
t.Fatalf("restore of the current version: %d %s, want 409", rec.Code, rec.Body)
}
if !strings.Contains(rec.Body.String(), "already the current content") {
t.Fatalf("409 body = %q", rec.Body)
}
after := journalsAt(t, dir)
if len(after) != len(before) {
t.Fatalf("a refused restore wrote a journal: %v → %v", before, after)
}
for name, data := range before {
if after[name] != data {
t.Fatalf("journal %s changed on a refused restore", name)
}
}
// the older version is still restorable — and once it is the current
// content, restoring IT becomes the no-op instead.
if rec := do(t, h, "POST", base+"restore", map[string]string{"path": "f.md", "sha": shaOf("v1")}); rec.Code != 200 {
t.Fatalf("restore of an older version: %d %s", rec.Code, rec.Body)
}
if rec := do(t, h, "POST", base+"restore", map[string]string{"path": "f.md", "sha": shaOf("v1")}); rec.Code != http.StatusConflict {
t.Fatalf("re-restore of what is now current: %d %s, want 409", rec.Code, rec.Body)
}
}
// One writer per journal: the hub appends to its own key and to nothing else.
func TestRestoreOnlyTouchesOwnJournal(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "f.md", "v1")
f.put("dev2", "f.md", "v2")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "f.md", "sha": shaOf("v1")})
if rec.Code != 200 {
t.Fatalf("restore: %d %s", rec.Code, rec.Body)
}
after := journalsAt(t, dir)
for _, dev := range []string{"dev1.jsonl", "dev2.jsonl"} {
if after[dev] != before[dev] {
t.Fatalf("restore rewrote %s", dev)
}
}
own := after[webDevice.ID+".jsonl"]
if own == "" || !strings.HasPrefix(own, before[webDevice.ID+".jsonl"]) {
t.Fatal("the server's own journal was not appended to")
}
ops, err := journal.Parse([]byte(own))
if err != nil || len(ops) != 1 {
t.Fatalf("own journal ops = %d (%v), want 1", len(ops), err)
}
}
// A blocked plan blocks restores too, even though a restore stores no new
// bytes — and nothing is journaled when it does.
func TestRestoreQuotaBlocked(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "f.md", "v1")
f.put("dev1", "f.md", "v2")
q := &recQuota{denyW: true}
srv.Quota = q
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "f.md", "sha": shaOf("v1")})
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("blocked restore: %d %s, want 413", rec.Code, rec.Body)
}
if len(q.usage) != 0 {
t.Fatalf("a denied restore recorded usage: %+v", q.usage)
}
if len(q.writes) != 1 || q.writes[0].bytes != 0 {
t.Fatalf("CheckWrite calls = %+v, want one for 0 bytes", q.writes)
}
for name, data := range journalsAt(t, dir) {
if data != before[name] {
t.Fatalf("denied restore wrote journal %s", name)
}
}
// allowed again: it goes through, and records zero bytes
q.denyW = false
if rec := do(t, h, "POST", base+"restore", map[string]string{"path": "f.md", "sha": shaOf("v1")}); rec.Code != 200 {
t.Fatalf("restore after unblocking: %d %s", rec.Code, rec.Body)
}
if len(q.usage) != 1 || q.usage[0].bytes != 0 {
t.Fatalf("RecordUsage = %+v, want one call for 0 bytes", q.usage)
}
}
// A deleted file comes back: the blob outlives the delete op.
func TestRestoreAfterDelete(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "gone.md", "still here")
f.del("dev1", "gone.md")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
if rec := do(t, h, "GET", base+"tree", nil); strings.Contains(rec.Body.String(), "gone.md") {
t.Fatal("file should be deleted before the restore")
}
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "gone.md", "sha": shaOf("still here")})
if rec.Code != 200 {
t.Fatalf("restore: %d %s", rec.Code, rec.Body)
}
if rec := do(t, h, "GET", base+"tree", nil); !strings.Contains(rec.Body.String(), "gone.md") {
t.Fatalf("file did not come back: %s", rec.Body)
}
if rec := do(t, h, "GET", base+"file?path=gone.md", nil); rec.Body.String() != "still here" {
t.Fatalf("restored content = %q", rec.Body)
}
// The no-op guard must not fire on a deleted path: replay drops it, so
// there is no current content for the blob to equal. Now that the file is
// back, though, the same call IS a no-op.
if rec := do(t, h, "POST", base+"restore", map[string]string{"path": "gone.md", "sha": shaOf("still here")}); rec.Code != http.StatusConflict {
t.Fatalf("restore of the now-current content: %d %s, want 409", rec.Code, rec.Body)
}
}
// Restore is a write: a hub running without --upload stays read-only.
func TestRestoreNeedsUploadsEnabled(t *testing.T) {
srv, p, root := newHub(t, false, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
f.put("dev1", "f.md", "v1")
h := srv.Handler()
rec := do(t, h, "POST", "/api/p/"+p.ID+"/restore", map[string]string{"path": "f.md", "sha": shaOf("v1")})
if rec.Code != http.StatusForbidden {
t.Fatalf("restore on a read-only hub: %d %s, want 403", rec.Code, rec.Body)
}
}
// The single-volume (plain folder) server has no journal to look a version
// up in, so it has no restore route at all.
func TestRestoreNotOnSingleVolume(t *testing.T) {
f := newFakeRemote(t)
f.put("dev1", "f.md", "v1")
h := f.uploadServer(nil).Handler()
rec := do(t, h, "POST", "/api/restore", map[string]string{"path": "f.md", "sha": shaOf("v1")})
if rec.Code < 400 {
t.Fatalf("single-volume restore: %d, want no such route", rec.Code)
}
}
// A moved file keeps its past. Without the chain, restore refuses every
// version written before the move — the file's own history becomes
// unreachable the moment someone drags it into a folder.
func TestRestoreReachesVersionsFromBeforeAMove(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
at := time.Now().Add(-time.Hour)
f.putAt("dev1", "plan.md", "v1", at)
f.putAt("dev1", "plan.md", "v2 longer", at.Add(time.Minute))
// the move: same device, same blob, one cycle
f.putAt("dev1", "notes/plan.md", "v2 longer", at.Add(2*time.Minute))
f.delAt("dev1", "plan.md", at.Add(2*time.Minute+time.Second))
// an unrelated file later takes the old address
f.putAt("dev1", "plan.md", "not the same file", at.Add(time.Hour))
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
rec := do(t, h, "POST", base+"restore",
map[string]string{"path": "notes/plan.md", "sha": shaOf("v1")})
if rec.Code != 200 {
t.Fatalf("restore across a move: %d %s", rec.Code, rec.Body)
}
if rec := do(t, h, "GET", base+"file?path=notes/plan.md", nil); rec.Body.String() != "v1" {
t.Fatalf("file after restore = %q, want v1", rec.Body)
}
// The successor at the old address is a different file, not an ancestor.
rec = do(t, h, "POST", base+"restore",
map[string]string{"path": "notes/plan.md", "sha": shaOf("not the same file")})
if rec.Code != http.StatusNotFound {
t.Fatalf("restore of the successor's blob: %d %s, want 404", rec.Code, rec.Body)
}
}
// The same chain, on the history feed.
func TestHistoryFollowsAMove(t *testing.T) {
srv, p, root := newHub(t, true, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
at := time.Now().Add(-time.Hour)
f.putAt("dev1", "plan.md", "v1", at)
f.putAt("dev1", "plan.md", "v2", at.Add(time.Minute))
f.putAt("dev1", "notes/plan.md", "v2", at.Add(2*time.Minute))
f.delAt("dev1", "plan.md", at.Add(2*time.Minute+time.Second))
f.putAt("dev1", "plan.md", "unrelated", at.Add(time.Hour))
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
entries := historyOf(t, h, base, "notes/plan.md")
var blobs []string
for _, e := range entries {
blobs = append(blobs, e.Blob)
}
if len(entries) != 3 {
t.Fatalf("history = %d entries %v, want 3 (v1, v2, the move)", len(entries), blobs)
}
seen := map[string]bool{}
for _, b := range blobs {
seen[b] = true
}
if !seen[shaOf("v1")] {
t.Errorf("the pre-move v1 is missing from the feed: %v", blobs)
}
if seen[shaOf("unrelated")] {
t.Errorf("the unrelated file at the old address leaked in: %v", blobs)
}
}