mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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>
347 lines
12 KiB
Go
347 lines
12 KiB
Go
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)
|
||
}
|
||
}
|