Files
goclaw/internal/http/vault_handler_documents_test.go

535 lines
17 KiB
Go

package http
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/vault"
)
// masterTenant resolves TenantWorkspace to h.workspace unchanged, keeping the
// content-write tests pointed at a single temp dir.
var masterTenant = uuid.MustParse("0193a5b0-7000-7000-8000-000000000001")
func masterCtx() context.Context {
return store.WithTenantID(context.Background(), masterTenant)
}
// ---- writeDocumentContent: path & symlink containment (the blocking finding) ----
func TestWriteDocumentContent_WritesInsideWorkspace(t *testing.T) {
h := &VaultHandler{}
ws := t.TempDir()
hash, err := h.writeDocumentContent(ws, "notes/hello.md", []byte("hi"))
if err != nil {
t.Fatalf("writeDocumentContent: %v", err)
}
if want := vault.ContentHash([]byte("hi")); hash != want {
t.Errorf("hash = %q, want %q", hash, want)
}
got, err := os.ReadFile(filepath.Join(ws, "notes", "hello.md"))
if err != nil {
t.Fatalf("read written file: %v", err)
}
if string(got) != "hi" {
t.Errorf("content = %q, want %q", got, "hi")
}
}
func TestWriteDocumentContent_EmptyContent(t *testing.T) {
h := &VaultHandler{}
ws := t.TempDir()
hash, err := h.writeDocumentContent(ws, "empty.md", []byte(""))
if err != nil {
t.Fatalf("writeDocumentContent: %v", err)
}
if want := vault.ContentHash([]byte("")); hash != want {
t.Errorf("hash = %q, want %q", hash, want)
}
fi, err := os.Stat(filepath.Join(ws, "empty.md"))
if err != nil {
t.Fatalf("stat empty file: %v", err)
}
if fi.Size() != 0 {
t.Errorf("size = %d, want 0", fi.Size())
}
}
func TestWriteDocumentContent_RejectsParentEscape(t *testing.T) {
h := &VaultHandler{}
ws := t.TempDir()
_, err := h.writeDocumentContent(ws, "../escape.md", []byte("x"))
if !errors.Is(err, os.ErrInvalid) {
t.Fatalf("err = %v, want os.ErrInvalid", err)
}
if _, statErr := os.Stat(filepath.Join(filepath.Dir(ws), "escape.md")); statErr == nil {
t.Error("file escaped the workspace via ../")
}
}
// A symlink that lives inside the workspace but points outside it must not be
// followed — this is the exact escape the lexical prefix check missed.
func TestWriteDocumentContent_RejectsSymlinkDirEscape(t *testing.T) {
h := &VaultHandler{}
ws := t.TempDir()
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(ws, "link")); err != nil {
t.Fatalf("symlink: %v", err)
}
_, err := h.writeDocumentContent(ws, "link/pwn.md", []byte("x"))
if !errors.Is(err, os.ErrInvalid) {
t.Fatalf("err = %v, want os.ErrInvalid", err)
}
if _, statErr := os.Stat(filepath.Join(outside, "pwn.md")); statErr == nil {
t.Error("file written outside workspace through symlinked directory")
}
}
// A symlink planted at the final path component must be refused rather than
// followed, so an attacker can't redirect a write onto an existing outside file.
func TestWriteDocumentContent_RejectsFinalComponentSymlink(t *testing.T) {
h := &VaultHandler{}
ws := t.TempDir()
outside := t.TempDir()
outsideFile := filepath.Join(outside, "target.md")
if err := os.WriteFile(outsideFile, []byte("original"), 0o644); err != nil {
t.Fatalf("seed outside file: %v", err)
}
if err := os.Symlink(outsideFile, filepath.Join(ws, "evil.md")); err != nil {
t.Fatalf("symlink: %v", err)
}
_, err := h.writeDocumentContent(ws, "evil.md", []byte("pwned"))
if !errors.Is(err, os.ErrInvalid) {
t.Fatalf("err = %v, want os.ErrInvalid", err)
}
got, _ := os.ReadFile(outsideFile)
if string(got) != "original" {
t.Errorf("outside file clobbered through final-component symlink: %q", got)
}
}
// ---- handler-level: extension whitelist, event ordering, empty PUT, orphan ----
// fakeVaultStore embeds the interface (unimplemented methods panic if hit) and
// overrides only the two methods the content path touches.
type fakeVaultStore struct {
store.VaultStore
docs map[string]*store.VaultDocument
upserted bool
upsertErr error
upsertCall int
}
func newFakeVaultStore() *fakeVaultStore {
return &fakeVaultStore{docs: map[string]*store.VaultDocument{}}
}
func (f *fakeVaultStore) UpsertDocument(_ context.Context, doc *store.VaultDocument) error {
f.upsertCall++
if f.upsertErr != nil {
return f.upsertErr
}
if doc.ID == "" {
doc.ID = "doc-" + uuid.NewString()
}
f.upserted = true
f.docs[doc.ID] = doc
return nil
}
func (f *fakeVaultStore) GetDocumentByID(_ context.Context, _ /*tenantID*/, id string) (*store.VaultDocument, error) {
return f.docs[id], nil
}
// fakeEventBus captures published events and records whether the DB upsert had
// already happened at publish time, to assert ordering.
type fakeEventBus struct {
store *fakeVaultStore
published []eventbus.DomainEvent
upsertedAtFire bool
}
func (b *fakeEventBus) Publish(e eventbus.DomainEvent) {
if b.store != nil {
b.upsertedAtFire = b.store.upserted
}
b.published = append(b.published, e)
}
func (b *fakeEventBus) Subscribe(eventbus.EventType, eventbus.DomainEventHandler) func() {
return func() {}
}
func (b *fakeEventBus) Start(context.Context) {}
func (b *fakeEventBus) Drain(time.Duration) error { return nil }
type fakeTeamAccessStore struct {
allowed bool
}
func (f *fakeTeamAccessStore) GrantTeamAccess(context.Context, uuid.UUID, string, string, string) error {
return nil
}
func (f *fakeTeamAccessStore) RevokeTeamAccess(context.Context, uuid.UUID, string) error {
return nil
}
func (f *fakeTeamAccessStore) ListTeamGrants(context.Context, uuid.UUID) ([]store.TeamUserGrant, error) {
return nil, nil
}
func (f *fakeTeamAccessStore) ListUserTeams(context.Context, string) ([]store.TeamData, error) {
return nil, nil
}
func (f *fakeTeamAccessStore) HasTeamAccess(context.Context, uuid.UUID, string) (bool, error) {
return f.allowed, nil
}
func newJSONRequest(t *testing.T, method, target string, body any) *http.Request {
t.Helper()
buf, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
req := httptest.NewRequest(method, target, bytes.NewReader(buf))
req.Header.Set("Content-Type", "application/json")
return req.WithContext(masterCtx())
}
func TestHandleCreateDocument_RejectsDisallowedExtension(t *testing.T) {
h := &VaultHandler{} // extension check fires before any store/workspace access
req := newJSONRequest(t, http.MethodPost, "/v1/vault/documents", map[string]any{
"path": "payload.exe",
"title": "x",
"content": "data",
})
rr := httptest.NewRecorder()
h.handleCreateDocument(rr, req)
assertBadRequest(t, rr, "unsupported file type")
}
func TestHandleCreateDocument_WritesContentAndPublishesAfterUpsert(t *testing.T) {
st := newFakeVaultStore()
bus := &fakeEventBus{store: st}
ws := t.TempDir()
h := &VaultHandler{store: st, eventBus: bus, workspace: ws}
req := newJSONRequest(t, http.MethodPost, "/v1/vault/documents", map[string]any{
"path": "decisions/adr-001.md",
"title": "ADR 001",
"content": "# Decision\nuse postgres",
})
rr := httptest.NewRecorder()
h.handleCreateDocument(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201 (body: %s)", rr.Code, rr.Body.String())
}
// File materialised on disk.
got, err := os.ReadFile(filepath.Join(ws, "decisions", "adr-001.md"))
if err != nil {
t.Fatalf("read written doc: %v", err)
}
if string(got) != "# Decision\nuse postgres" {
t.Errorf("content = %q", got)
}
// Exactly one enrichment event, fired AFTER the DB upsert.
if len(bus.published) != 1 {
t.Fatalf("published %d events, want 1", len(bus.published))
}
if !bus.upsertedAtFire {
t.Error("event published before DB upsert — enrichment worker could race ahead of the row")
}
if bus.published[0].Type != eventbus.EventVaultDocUpserted {
t.Errorf("event type = %v, want %v", bus.published[0].Type, eventbus.EventVaultDocUpserted)
}
}
func TestHandleCreateDocument_NoContentSkipsWriteAndEvent(t *testing.T) {
st := newFakeVaultStore()
bus := &fakeEventBus{store: st}
ws := t.TempDir()
h := &VaultHandler{store: st, eventBus: bus, workspace: ws}
req := newJSONRequest(t, http.MethodPost, "/v1/vault/documents", map[string]any{
"path": "meta-only.md",
"title": "Meta only",
})
rr := httptest.NewRecorder()
h.handleCreateDocument(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201 (body: %s)", rr.Code, rr.Body.String())
}
if _, statErr := os.Stat(filepath.Join(ws, "meta-only.md")); statErr == nil {
t.Error("metadata-only create must not write a file")
}
if len(bus.published) != 0 {
t.Errorf("metadata-only create published %d events, want 0", len(bus.published))
}
}
// Documents the orphan-file behaviour: when the DB upsert fails after the bytes
// are already on disk, the handler returns 500 and the file is intentionally
// left in place (no rollback). If that ever changes, this test should change
// with it.
func TestHandleCreateDocument_UpsertFailureLeavesFile(t *testing.T) {
st := newFakeVaultStore()
st.upsertErr = errors.New("db down")
bus := &fakeEventBus{store: st}
ws := t.TempDir()
h := &VaultHandler{store: st, eventBus: bus, workspace: ws}
req := newJSONRequest(t, http.MethodPost, "/v1/vault/documents", map[string]any{
"path": "orphan.md",
"title": "Orphan",
"content": "written before upsert",
})
rr := httptest.NewRecorder()
h.handleCreateDocument(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (body: %s)", rr.Code, rr.Body.String())
}
if _, statErr := os.Stat(filepath.Join(ws, "orphan.md")); statErr != nil {
t.Errorf("file should remain after upsert failure (orphan accepted): %v", statErr)
}
if len(bus.published) != 0 {
t.Errorf("no event should fire when upsert fails, got %d", len(bus.published))
}
}
func TestHandleUpdateDocument_EmptyContentClearsFile(t *testing.T) {
st := newFakeVaultStore()
st.docs["existing-1"] = &store.VaultDocument{
ID: "existing-1",
TenantID: masterTenant.String(),
Path: "notes/keep.md",
Title: "Keep",
DocType: "note",
Scope: "shared",
}
bus := &fakeEventBus{store: st}
ws := t.TempDir()
h := &VaultHandler{store: st, eventBus: bus, workspace: ws}
empty := ""
req := newJSONRequest(t, http.MethodPut, "/v1/vault/documents/existing-1", map[string]any{
"content": empty,
})
req.SetPathValue("docID", "existing-1")
rr := httptest.NewRecorder()
h.handleUpdateDocument(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rr.Code, rr.Body.String())
}
fi, err := os.Stat(filepath.Join(ws, "notes", "keep.md"))
if err != nil {
t.Fatalf("stat cleared file: %v", err)
}
if fi.Size() != 0 {
t.Errorf("file size = %d, want 0 (empty content clears file)", fi.Size())
}
if st.docs["existing-1"].ContentHash != vault.ContentHash([]byte("")) {
t.Errorf("content_hash = %q, want empty-content hash", st.docs["existing-1"].ContentHash)
}
if len(bus.published) != 1 {
t.Errorf("published %d events, want 1", len(bus.published))
}
}
// Locks the "no change" contract on PUT: omitting `content` (nil pointer in
// the body struct) must not touch the disk or fire an enrichment event, even
// when other metadata fields are present.
func TestHandleUpdateDocument_NilContentSkipsWriteAndEvent(t *testing.T) {
st := newFakeVaultStore()
st.docs["existing-2"] = &store.VaultDocument{
ID: "existing-2",
TenantID: masterTenant.String(),
Path: "notes/keep.md",
Title: "Old",
DocType: "note",
Scope: "shared",
ContentHash: "preexisting-hash",
}
bus := &fakeEventBus{store: st}
ws := t.TempDir()
h := &VaultHandler{store: st, eventBus: bus, workspace: ws}
// content field intentionally omitted → *string is nil → no write, no event.
req := newJSONRequest(t, http.MethodPut, "/v1/vault/documents/existing-2", map[string]any{
"title": "Renamed but content untouched",
})
req.SetPathValue("docID", "existing-2")
rr := httptest.NewRecorder()
h.handleUpdateDocument(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rr.Code, rr.Body.String())
}
if _, statErr := os.Stat(filepath.Join(ws, "notes", "keep.md")); statErr == nil {
t.Error("nil content must not materialise a file")
}
if st.docs["existing-2"].ContentHash != "preexisting-hash" {
t.Errorf("content_hash = %q, want unchanged 'preexisting-hash'", st.docs["existing-2"].ContentHash)
}
if len(bus.published) != 0 {
t.Errorf("nil-content PUT must not publish events, got %d", len(bus.published))
}
}
func TestHandleUpdateDocument_TeamDocRequiresMembershipBeforeContentWrite(t *testing.T) {
teamID := uuid.NewString()
st := newFakeVaultStore()
st.docs["team-doc"] = &store.VaultDocument{
ID: "team-doc",
TenantID: masterTenant.String(),
TeamID: &teamID,
Path: "teams/" + teamID + "/private.md",
Title: "Private",
DocType: "note",
Scope: "team",
}
bus := &fakeEventBus{store: st}
ws := t.TempDir()
h := &VaultHandler{
store: st,
teamAccess: &fakeTeamAccessStore{allowed: false},
eventBus: bus,
workspace: ws,
}
ctx := store.WithUserID(masterCtx(), "non-member")
req := newJSONRequest(t, http.MethodPut, "/v1/vault/documents/team-doc", map[string]any{
"content": "should not be written",
}).WithContext(ctx)
req.SetPathValue("docID", "team-doc")
rr := httptest.NewRecorder()
h.handleUpdateDocument(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (body: %s)", rr.Code, rr.Body.String())
}
if _, statErr := os.Stat(filepath.Join(ws, "teams", teamID, "private.md")); statErr == nil {
t.Fatal("non-member content update wrote file before team access check")
}
if st.upsertCall != 0 {
t.Fatalf("upsertCall = %d, want 0", st.upsertCall)
}
if len(bus.published) != 0 {
t.Fatalf("published %d events, want 0", len(bus.published))
}
}
// Handler-level guard at vault_handler_documents.go path validation must reject
// "..", short-circuiting before writeDocumentContent even runs.
func TestHandleCreateDocument_RejectsParentTraversalPath(t *testing.T) {
h := &VaultHandler{} // path check runs before any store/workspace access
req := newJSONRequest(t, http.MethodPost, "/v1/vault/documents", map[string]any{
"path": "../../etc/passwd.md",
"title": "x",
"content": "evil",
})
rr := httptest.NewRecorder()
h.handleCreateDocument(rr, req)
assertBadRequest(t, rr, "invalid path")
}
// Mirrors PUT semantics on POST: `content: ""` writes a 0-byte file and
// fires the enrichment event (the I4 fix that aligned POST/PUT).
func TestHandleCreateDocument_EmptyContentWritesEmptyFile(t *testing.T) {
st := newFakeVaultStore()
bus := &fakeEventBus{store: st}
ws := t.TempDir()
h := &VaultHandler{store: st, eventBus: bus, workspace: ws}
req := newJSONRequest(t, http.MethodPost, "/v1/vault/documents", map[string]any{
"path": "placeholder.md",
"title": "Placeholder",
"content": "",
})
rr := httptest.NewRecorder()
h.handleCreateDocument(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201 (body: %s)", rr.Code, rr.Body.String())
}
fi, err := os.Stat(filepath.Join(ws, "placeholder.md"))
if err != nil {
t.Fatalf("stat empty file: %v", err)
}
if fi.Size() != 0 {
t.Errorf("size = %d, want 0", fi.Size())
}
if len(bus.published) != 1 {
t.Errorf("published %d events, want 1", len(bus.published))
}
}
// B1 regression: non-master tenants resolve to workspace/tenants/{slug}/ which
// is created on demand. The very first JSON content write into a fresh tenant
// must NOT 500 because EvalSymlinks can't find that dir — writeDocumentContent
// is responsible for creating it (mirrors what handleUpload does).
func TestHandleCreateDocument_NonMasterTenantFirstContentWrite(t *testing.T) {
st := newFakeVaultStore()
bus := &fakeEventBus{store: st}
ws := t.TempDir()
h := &VaultHandler{store: st, eventBus: bus, workspace: ws}
nonMaster := uuid.MustParse("01999999-1111-7000-8000-000000000abc")
ctx := store.WithTenantSlug(store.WithTenantID(context.Background(), nonMaster), "acme")
// newJSONRequest builds against masterCtx by default; WithContext swaps in
// the non-master tenant context for this single test.
req := newJSONRequest(t, http.MethodPost, "/v1/vault/documents", map[string]any{
"path": "notes/first.md",
"title": "First",
"content": "hello from acme",
}).WithContext(ctx)
rr := httptest.NewRecorder()
h.handleCreateDocument(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201 (body: %s)", rr.Code, rr.Body.String())
}
tenantDir := filepath.Join(ws, "tenants", "acme")
got, err := os.ReadFile(filepath.Join(tenantDir, "notes", "first.md"))
if err != nil {
t.Fatalf("read non-master tenant doc: %v", err)
}
if string(got) != "hello from acme" {
t.Errorf("content = %q", got)
}
if len(bus.published) != 1 {
t.Errorf("published %d events, want 1", len(bus.published))
}
}