Files

204 lines
6.9 KiB
Go
Raw Permalink Normal View History

package webapp
import (
"net/http"
"path/filepath"
"strings"
"testing"
"github.com/runbear-io/beardrive/internal/journal"
)
// Removing a file journals one delete op under the hub's own device: the file
// leaves the tree, history gains a delete row, and no other device's journal
// is touched.
func TestRemoveWritesDeleteOp(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "ideas.md", "an agent wrote this")
f.put("dev2", "keep.md", "untouched")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
rec := do(t, h, "POST", base+"remove", map[string]string{"path": "ideas.md"})
var out struct {
OK bool `json:"ok"`
Path string `json:"path"`
}
mustJSON(t, rec, &out)
if !out.OK || out.Path != "ideas.md" {
t.Fatalf("remove response = %+v", out)
}
if rec := do(t, h, "GET", base+"tree", nil); strings.Contains(rec.Body.String(), "ideas.md") {
t.Fatalf("removed file still in the tree: %s", rec.Body)
}
entries := historyOf(t, h, base, "ideas.md")
if len(entries) != 2 {
t.Fatalf("history = %d entries, want 2", len(entries))
}
if newest := entries[0]; newest.Kind != string(journal.KindDelete) || newest.Note != "remove ideas.md" {
t.Fatalf("newest entry = %+v, want a delete noted %q", newest, "remove ideas.md")
}
// One writer per journal: only our own key grew, by exactly one op.
after := journalsAt(t, dir)
for _, dev := range []string{"dev1.jsonl", "dev2.jsonl"} {
if after[dev] != before[dev] {
t.Fatalf("remove 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)
}
if op := ops[0]; op.Kind != journal.KindDelete || op.Path != "ideas.md" || op.Blob != "" || op.Size != 0 {
t.Fatalf("journaled op = %+v, want a contentless delete of ideas.md", op)
}
}
// A path that is not in the current state is a 404, and writes nothing.
func TestRemoveUnknownPath(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "here.md", "v1")
f.put("dev1", "gone.md", "v1")
f.del("dev1", "gone.md")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
// never existed, and already deleted — both are "not a file right now"
for _, path := range []string{"nope.md", "gone.md"} {
rec := do(t, h, "POST", base+"remove", map[string]string{"path": path})
if rec.Code != http.StatusNotFound {
t.Fatalf("remove %q: %d %s, want 404", path, rec.Code, rec.Body)
}
}
assertJournalsUnchanged(t, dir, before)
}
// Path validation is the upload validator, so traversal and reserved names
// never reach the journal.
func TestRemoveBadPath(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")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
for _, path := range []string{"", "../x", "/abs", "x/", ".bdrive", "a/../b"} {
rec := do(t, h, "POST", base+"remove", map[string]string{"path": path})
if rec.Code != http.StatusBadRequest {
t.Fatalf("remove %q: %d %s, want 400", path, rec.Code, rec.Body)
}
}
assertJournalsUnchanged(t, dir, before)
}
// Remove is a write: a hub running without --upload stays read-only.
func TestRemoveNeedsUploadsEnabled(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+"/remove", map[string]string{"path": "f.md"})
if rec.Code != http.StatusForbidden {
t.Fatalf("remove on a read-only hub: %d %s, want 403", rec.Code, rec.Body)
}
}
// A blocked plan blocks removes too, even though a delete stores no bytes.
func TestRemoveQuotaBlocked(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")
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+"remove", map[string]string{"path": "f.md"})
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("blocked remove: %d %s, want 413", rec.Code, rec.Body)
}
if len(q.usage) != 0 {
t.Fatalf("a denied remove 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)
}
assertJournalsUnchanged(t, dir, before)
q.denyW = false
if rec := do(t, h, "POST", base+"remove", map[string]string{"path": "f.md"}); rec.Code != 200 {
t.Fatalf("remove 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)
}
}
// The round trip the UI promises: undo removes the file, and the DELETED row
// it leaves behind restores the original bytes.
func TestRemoveThenRestore(t *testing.T) {
srv, p, root := newHub(t, true, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
f.put("dev1", "ideas.md", "the bytes an agent wrote")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
if rec := do(t, h, "POST", base+"remove", map[string]string{"path": "ideas.md"}); rec.Code != 200 {
t.Fatalf("remove: %d %s", rec.Code, rec.Body)
}
// The delete row's restore target is the content it removed — the same
// predecessor lookup the history view does.
entries := historyOf(t, h, base, "ideas.md")
if len(entries) != 2 || entries[1].Blob != shaOf("the bytes an agent wrote") {
t.Fatalf("history after remove = %+v", entries)
}
if rec := do(t, h, "POST", base+"restore", map[string]string{"path": "ideas.md", "sha": entries[1].Blob}); rec.Code != 200 {
t.Fatalf("restore: %d %s", rec.Code, rec.Body)
}
if rec := do(t, h, "GET", base+"file?path=ideas.md", nil); rec.Body.String() != "the bytes an agent wrote" {
t.Fatalf("restored content = %q", rec.Body)
}
}
// The single-volume (plain folder) server has no journal to write a delete
// into, so it has no remove route at all.
func TestRemoveNotOnSingleVolume(t *testing.T) {
f := newFakeRemote(t)
f.put("dev1", "f.md", "v1")
h := f.uploadServer(nil).Handler()
rec := do(t, h, "POST", "/api/remove", map[string]string{"path": "f.md"})
if rec.Code < 400 {
t.Fatalf("single-volume remove: %d, want no such route", rec.Code)
}
}
func assertJournalsUnchanged(t *testing.T, dir string, before map[string]string) {
t.Helper()
after := journalsAt(t, dir)
if len(after) != len(before) {
t.Fatalf("a refused remove wrote a journal: %v → %v", before, after)
}
for name, data := range before {
if after[name] != data {
t.Fatalf("journal %s changed on a refused remove", name)
}
}
}