mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
9c5f4d5165
* fix(cli): construct auth login URL on CLI side to avoid 0.0.0.0 leak (TASK-839) The server builds the CLI auth-approval URL from r.Host, which echoes back whatever Host header the CLI sent. When the local pad server is bound to a bind-all address (e.g. --host 0.0.0.0), the CLI's own config points at that address, so the URL printed by `pad auth login` ends up as http://0.0.0.0:7777/auth/cli/{code} — a bind address, not a usable browser destination. Construct the URL on the CLI instead, using cfg.BrowserURL() (which already rewrites 0.0.0.0 / :: / empty to 127.0.0.1, and returns the explicit URL verbatim for Remote/Cloud). The server-issued auth_url field is now ignored; session_code is what we actually need and is already returned separately. Extracts a small cliAuthBrowserURL helper so the wiring is unit-testable and adds regression coverage for IPv4 bind-all, IPv6 bind-all, empty host, explicit loopback, explicit Remote URL, and trailing-slash trim. * chore(lint): remove unused readBundleAsBytes test helper golangci-lint v2.11.4 (CI) flags this as unused — it was added in the import-bundle test scaffolding (TASK-885 / TASK-891 era) but no caller ever picked it up. Removing it unblocks the lint gate on main. Reviewable in isolation; pure deletion, no behavior change.
635 lines
24 KiB
Go
635 lines
24 KiB
Go
package server
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/attachments"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// TestImportBundle_RoundTrip pins TASK-885's acceptance criterion:
|
|
// export from one workspace → import into a fresh server → items
|
|
// keep their pad-attachment:UUID references intact (rewritten to
|
|
// the new attachment ids), the blobs are reachable through the
|
|
// download endpoint, and the byte content matches the original
|
|
// upload.
|
|
//
|
|
// The most realistic test of a feature that touches three layers
|
|
// (export tar, import dispatch, attachment-reference remap). If any
|
|
// stage drops a UUID or fails to rewrite content, the final item
|
|
// won't render the image and this test catches it.
|
|
func TestImportBundle_RoundTrip(t *testing.T) {
|
|
// 1. Source workspace: upload an attachment, attach it to an
|
|
// item, embed the pad-attachment: reference in the item's
|
|
// markdown content.
|
|
src, srcSlug := testServerWithAttachments(t)
|
|
|
|
body := realPNG()
|
|
rr := doMultipartUpload(src, srcSlug, "logo.png", body)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var upload struct {
|
|
ID string `json:"id"`
|
|
URL string `json:"url"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &upload); err != nil {
|
|
t.Fatalf("decode upload: %v", err)
|
|
}
|
|
|
|
// Create an item that references the attachment in markdown.
|
|
itemContent := fmt.Sprintf("Hello world\n\n\n", upload.ID)
|
|
rr = doRequest(src, "POST", "/api/v1/workspaces/"+srcSlug+"/collections/docs/items",
|
|
map[string]any{"title": "With Image", "content": itemContent})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("create item: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// 2. Export the source workspace as a bundle.
|
|
rr = doRequest(src, "GET", "/api/v1/workspaces/"+srcSlug+"/export?format=tar", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("export: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
bundle := rr.Body.Bytes()
|
|
|
|
// 3. Spin up a fresh server (independent storage) and import the
|
|
// bundle into it. Using a separate server is what proves the
|
|
// UUID remap actually works — same-server import would
|
|
// accidentally pass even if the remap were broken.
|
|
dest, _ := testServerWithAttachments(t)
|
|
|
|
req := httptest.NewRequest("POST", "/api/v1/workspaces/import?name=Imported",
|
|
bytes.NewReader(bundle))
|
|
req.Header.Set("Content-Type", "application/gzip")
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
rr = httptest.NewRecorder()
|
|
dest.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("import: status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var newWS models.Workspace
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &newWS); err != nil {
|
|
t.Fatalf("decode new ws: %v", err)
|
|
}
|
|
|
|
// 4. The destination workspace must have a new attachments table
|
|
// with the rehydrated row, and the imported item's content
|
|
// must reference the NEW attachment id (not the old one).
|
|
rr = doRequest(dest, "GET", "/api/v1/workspaces/"+newWS.Slug+"/attachments", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("list attachments: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var attResp struct {
|
|
Attachments []struct {
|
|
ID string `json:"id"`
|
|
Filename string `json:"filename"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
} `json:"attachments"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &attResp); err != nil {
|
|
t.Fatalf("decode att list: %v", err)
|
|
}
|
|
if len(attResp.Attachments) != 1 {
|
|
t.Fatalf("imported attachments: got %d, want 1", len(attResp.Attachments))
|
|
}
|
|
newAttID := attResp.Attachments[0].ID
|
|
if newAttID == upload.ID {
|
|
t.Fatalf("attachment id was NOT remapped (got %s, original %s)", newAttID, upload.ID)
|
|
}
|
|
if attResp.Attachments[0].SizeBytes != int64(len(body)) {
|
|
t.Errorf("imported attachment size=%d, want %d",
|
|
attResp.Attachments[0].SizeBytes, len(body))
|
|
}
|
|
if attResp.Attachments[0].Filename != "logo.png" {
|
|
t.Errorf("imported attachment filename=%q, want logo.png",
|
|
attResp.Attachments[0].Filename)
|
|
}
|
|
|
|
// 5. Item content must reference the NEW attachment id (rewrite
|
|
// pass worked) and NOT the old one. Read the imported item
|
|
// via the docs collection's items endpoint.
|
|
rr = doRequest(dest, "GET", "/api/v1/workspaces/"+newWS.Slug+"/collections/docs/items", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("list items: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var items []models.Item
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &items); err != nil {
|
|
t.Fatalf("decode items: %v body=%s", err, rr.Body.String())
|
|
}
|
|
var imported *models.Item
|
|
for i := range items {
|
|
if items[i].Title == "With Image" {
|
|
imported = &items[i]
|
|
break
|
|
}
|
|
}
|
|
if imported == nil {
|
|
t.Fatalf("imported item not found; got %d items", len(items))
|
|
}
|
|
if !strings.Contains(imported.Content, "pad-attachment:"+newAttID) {
|
|
t.Errorf("imported content missing new attachment ref %s; content=%q",
|
|
newAttID, imported.Content)
|
|
}
|
|
if strings.Contains(imported.Content, "pad-attachment:"+upload.ID) {
|
|
t.Errorf("imported content still has stale old attachment ref %s; content=%q",
|
|
upload.ID, imported.Content)
|
|
}
|
|
|
|
// 6. Download the rehydrated blob and confirm bytes match the
|
|
// original upload. This is the strongest guarantee that the
|
|
// storage backend correctly received the bytes from the bundle.
|
|
rr = doRequest(dest, "GET",
|
|
"/api/v1/workspaces/"+newWS.Slug+"/attachments/"+newAttID, nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("download imported blob: %d", rr.Code)
|
|
}
|
|
if !bytes.Equal(rr.Body.Bytes(), body) {
|
|
t.Errorf("imported blob differs from original upload (got %d bytes, want %d)",
|
|
rr.Body.Len(), len(body))
|
|
}
|
|
}
|
|
|
|
// TestImportBundle_LegacyJSONStillWorks confirms the JSON dispatch
|
|
// still works alongside the new bundle path. Hits the same endpoint
|
|
// with JSON content-type — must route to the existing handler.
|
|
func TestImportBundle_LegacyJSONStillWorks(t *testing.T) {
|
|
srv, slug := testServerWithAttachments(t)
|
|
|
|
if rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/docs/items",
|
|
map[string]any{"title": "Plain", "content": "no attachments"}); rr.Code != http.StatusCreated {
|
|
t.Fatalf("create item: %d", rr.Code)
|
|
}
|
|
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/export", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("export json: %d", rr.Code)
|
|
}
|
|
jsonExport := rr.Body.Bytes()
|
|
|
|
// Import into a fresh server using JSON content-type.
|
|
dest, _ := testServerWithAttachments(t)
|
|
req := httptest.NewRequest("POST", "/api/v1/workspaces/import?name=JsonImport",
|
|
bytes.NewReader(jsonExport))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
rr = httptest.NewRecorder()
|
|
dest.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("legacy JSON import: status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestImportBundle_RejectsOutOfOrderTar pins the streaming-import
|
|
// invariant added in PR #306 round 1 (Codex P1): the bundle MUST
|
|
// place pad-export.json + manifest.json BEFORE any blob so the
|
|
// server can stream-rehydrate without buffering. Bundles that put
|
|
// blobs first would still work in the previous implementation but
|
|
// would force buffering of every blob — we now reject them up front
|
|
// with a clear error rather than silently buffering.
|
|
func TestImportBundle_RejectsOutOfOrderTar(t *testing.T) {
|
|
srv, _ := testServerWithAttachments(t)
|
|
|
|
// Hand-craft a tar.gz where a blob entry comes BEFORE
|
|
// pad-export.json. Strict-format violation.
|
|
var buf bytes.Buffer
|
|
gzw := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gzw)
|
|
if err := tw.WriteHeader(&tar.Header{Name: "attachments/abcd.png", Mode: 0o644, Size: 4}); err != nil {
|
|
t.Fatalf("write header: %v", err)
|
|
}
|
|
if _, err := tw.Write([]byte{0xde, 0xad, 0xbe, 0xef}); err != nil {
|
|
t.Fatalf("write blob: %v", err)
|
|
}
|
|
// Even if we'd write a manifest after, the blob coming first
|
|
// already violates the contract. Stop here.
|
|
tw.Close()
|
|
gzw.Close()
|
|
|
|
req := httptest.NewRequest("POST", "/api/v1/workspaces/import", bytes.NewReader(buf.Bytes()))
|
|
req.Header.Set("Content-Type", "application/gzip")
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("out-of-order bundle: status=%d, want 400; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "ordering") {
|
|
t.Errorf("expected ordering-violation error, got body=%s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestImportBundle_RejectsBadGzip pins the early-error path: a
|
|
// truncated/invalid gzip body must return 400, not 500. Catches
|
|
// regressions where the gzip-reader error gets swallowed and the
|
|
// handler proceeds with a half-decompressed stream.
|
|
func TestImportBundle_RejectsBadGzip(t *testing.T) {
|
|
srv := testServer(t)
|
|
// Wire the attachment registry so the dispatcher doesn't 503.
|
|
srv.SetAttachments(attachments.NewRegistry(), 0)
|
|
|
|
garbage := []byte("not a gzip stream")
|
|
req := httptest.NewRequest("POST", "/api/v1/workspaces/import",
|
|
bytes.NewReader(garbage))
|
|
req.Header.Set("Content-Type", "application/gzip")
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("bad gzip: status=%d, want 400; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestImportBundle_RejectsDuplicateExport pins the duplicate-entry
|
|
// guard added during the PLAN-890 audit (TASK-891). A bundle that
|
|
// contains two pad-export.json entries used to call ImportWorkspace
|
|
// twice — leaving the first workspace as an orphan with no
|
|
// attachments. The handler now rejects the second occurrence with a
|
|
// 400 AND rolls back the partial workspace from the first occurrence
|
|
// so a malformed bundle can't pile up half-imported workspaces in
|
|
// the destination. Codex P1 on PR #308.
|
|
func TestImportBundle_RejectsDuplicateExport(t *testing.T) {
|
|
// Build a real, valid pad-export.json by exporting an empty
|
|
// workspace from a live server, then assemble a tar.gz that
|
|
// includes it twice.
|
|
src, srcSlug := testServerWithAttachments(t)
|
|
rr := doRequest(src, "GET", "/api/v1/workspaces/"+srcSlug+"/export", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("export src: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
exportJSON := rr.Body.Bytes()
|
|
|
|
var buf bytes.Buffer
|
|
gzw := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gzw)
|
|
for i := 0; i < 2; i++ {
|
|
if err := tw.WriteHeader(&tar.Header{Name: "pad-export.json", Mode: 0o644, Size: int64(len(exportJSON))}); err != nil {
|
|
t.Fatalf("write header: %v", err)
|
|
}
|
|
if _, err := tw.Write(exportJSON); err != nil {
|
|
t.Fatalf("write export: %v", err)
|
|
}
|
|
}
|
|
tw.Close()
|
|
gzw.Close()
|
|
|
|
dest, _ := testServerWithAttachments(t)
|
|
req := httptest.NewRequest("POST", "/api/v1/workspaces/import?name=DupExport",
|
|
bytes.NewReader(buf.Bytes()))
|
|
req.Header.Set("Content-Type", "application/gzip")
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
rr = httptest.NewRecorder()
|
|
dest.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("duplicate export: status=%d, want 400; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "duplicate") {
|
|
t.Errorf("expected duplicate-export error, got body=%s", rr.Body.String())
|
|
}
|
|
|
|
// Verify the partial workspace from the first pad-export.json
|
|
// occurrence got rolled back. Listing must NOT include "DupExport".
|
|
rr = doRequest(dest, "GET", "/api/v1/workspaces", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("list workspaces: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
if strings.Contains(rr.Body.String(), `"name":"DupExport"`) || strings.Contains(rr.Body.String(), `"slug":"DupExport"`) {
|
|
t.Errorf("partial workspace was NOT rolled back; listing body=%s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestImportBundle_RejectsDuplicateManifest pins the matching guard
|
|
// for attachments/manifest.json. A second occurrence would silently
|
|
// overwrite manifestByPath and any blobs that matched the first
|
|
// manifest's keys would look orphaned — mark as a consumed-skip and
|
|
// be lost.
|
|
func TestImportBundle_RejectsDuplicateManifest(t *testing.T) {
|
|
src, srcSlug := testServerWithAttachments(t)
|
|
rr := doRequest(src, "GET", "/api/v1/workspaces/"+srcSlug+"/export", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("export src: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
exportJSON := rr.Body.Bytes()
|
|
|
|
// Hand-build the manifest bytes — tiny empty manifest is fine for
|
|
// this test; we only care that it parses, and a second copy
|
|
// triggers the guard.
|
|
manifest := []byte(`{"version":1,"entries":[]}`)
|
|
|
|
var buf bytes.Buffer
|
|
gzw := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gzw)
|
|
if err := tw.WriteHeader(&tar.Header{Name: "pad-export.json", Mode: 0o644, Size: int64(len(exportJSON))}); err != nil {
|
|
t.Fatalf("write export header: %v", err)
|
|
}
|
|
if _, err := tw.Write(exportJSON); err != nil {
|
|
t.Fatalf("write export: %v", err)
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
if err := tw.WriteHeader(&tar.Header{Name: "attachments/manifest.json", Mode: 0o644, Size: int64(len(manifest))}); err != nil {
|
|
t.Fatalf("write manifest header: %v", err)
|
|
}
|
|
if _, err := tw.Write(manifest); err != nil {
|
|
t.Fatalf("write manifest: %v", err)
|
|
}
|
|
}
|
|
tw.Close()
|
|
gzw.Close()
|
|
|
|
dest, _ := testServerWithAttachments(t)
|
|
req := httptest.NewRequest("POST", "/api/v1/workspaces/import?name=DupManifest",
|
|
bytes.NewReader(buf.Bytes()))
|
|
req.Header.Set("Content-Type", "application/gzip")
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
rr = httptest.NewRecorder()
|
|
dest.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("duplicate manifest: status=%d, want 400; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "duplicate") {
|
|
t.Errorf("expected duplicate-manifest error, got body=%s", rr.Body.String())
|
|
}
|
|
|
|
// Verify the partial workspace from the first pad-export.json
|
|
// occurrence got rolled back when the duplicate manifest was
|
|
// detected. Same rollback contract as TestImportBundle_RejectsDuplicateExport.
|
|
rr = doRequest(dest, "GET", "/api/v1/workspaces", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("list workspaces: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
if strings.Contains(rr.Body.String(), `"name":"DupManifest"`) || strings.Contains(rr.Body.String(), `"slug":"DupManifest"`) {
|
|
t.Errorf("partial workspace was NOT rolled back; listing body=%s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestImportBundle_RejectsPathTraversal pins the defense-in-depth
|
|
// reject of tar entries with `..` segments or absolute paths. The
|
|
// storage backend is hash-keyed so a malicious entry name can't
|
|
// actually escape the attachment store, but rejecting up front
|
|
// keeps the audit story unambiguous and means hand-edited bundles
|
|
// fail loudly rather than slipping through the default arm.
|
|
//
|
|
// Cases involving NUL bytes are covered by the unit test on
|
|
// isSafeBundleEntryName below — Go's archive/tar refuses to encode a
|
|
// NUL-bearing header name, so we can't exercise that path through
|
|
// the integration handler.
|
|
func TestImportBundle_RejectsPathTraversal(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
entry string
|
|
wantErr string
|
|
}{
|
|
{"dot-dot in path", "attachments/../../etc/passwd", "unsafe entry name"},
|
|
{"absolute unix path", "/etc/passwd", "unsafe entry name"},
|
|
{"absolute windows path", "\\windows\\system32\\config", "unsafe entry name"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
gzw := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gzw)
|
|
if err := tw.WriteHeader(&tar.Header{Name: tc.entry, Mode: 0o644, Size: 4}); err != nil {
|
|
t.Fatalf("write header: %v", err)
|
|
}
|
|
if _, err := tw.Write([]byte{0xde, 0xad, 0xbe, 0xef}); err != nil {
|
|
t.Fatalf("write blob: %v", err)
|
|
}
|
|
tw.Close()
|
|
gzw.Close()
|
|
|
|
srv, _ := testServerWithAttachments(t)
|
|
req := httptest.NewRequest("POST", "/api/v1/workspaces/import",
|
|
bytes.NewReader(buf.Bytes()))
|
|
req.Header.Set("Content-Type", "application/gzip")
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("%s: status=%d, want 400; body=%s", tc.name, rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), tc.wantErr) {
|
|
t.Errorf("%s: expected %q in body, got %s", tc.name, tc.wantErr, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestImportBundle_PathTraversalAfterExportRollsBack pins Codex P1
|
|
// round 3 on PR #308: a path-traversal entry that follows a valid
|
|
// pad-export.json must trigger the workspace rollback, not just
|
|
// return a 400 with the workspace left behind. Earlier the
|
|
// path-traversal early-return passed nil instead of ws, so the
|
|
// handler skipped the cleanup cascade.
|
|
func TestImportBundle_PathTraversalAfterExportRollsBack(t *testing.T) {
|
|
// Real pad-export.json so the first entry creates a workspace.
|
|
src, srcSlug := testServerWithAttachments(t)
|
|
rr := doRequest(src, "GET", "/api/v1/workspaces/"+srcSlug+"/export", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("export src: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
exportJSON := rr.Body.Bytes()
|
|
|
|
var buf bytes.Buffer
|
|
gzw := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gzw)
|
|
if err := tw.WriteHeader(&tar.Header{Name: "pad-export.json", Mode: 0o644, Size: int64(len(exportJSON))}); err != nil {
|
|
t.Fatalf("write export header: %v", err)
|
|
}
|
|
if _, err := tw.Write(exportJSON); err != nil {
|
|
t.Fatalf("write export: %v", err)
|
|
}
|
|
// Inject a path-traversal entry AFTER the valid pad-export.json
|
|
// so workspace creation has already happened by the time the
|
|
// guard fires.
|
|
if err := tw.WriteHeader(&tar.Header{Name: "attachments/../../etc/passwd", Mode: 0o644, Size: 4}); err != nil {
|
|
t.Fatalf("write evil header: %v", err)
|
|
}
|
|
if _, err := tw.Write([]byte{0xde, 0xad, 0xbe, 0xef}); err != nil {
|
|
t.Fatalf("write evil body: %v", err)
|
|
}
|
|
tw.Close()
|
|
gzw.Close()
|
|
|
|
dest, _ := testServerWithAttachments(t)
|
|
req := httptest.NewRequest("POST", "/api/v1/workspaces/import?name=PostExportTraversal",
|
|
bytes.NewReader(buf.Bytes()))
|
|
req.Header.Set("Content-Type", "application/gzip")
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
rr = httptest.NewRecorder()
|
|
dest.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("traversal-after-export: status=%d, want 400; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "unsafe entry name") {
|
|
t.Errorf("expected unsafe-entry error, got body=%s", rr.Body.String())
|
|
}
|
|
|
|
// Workspace must be gone — the partial create from the valid
|
|
// pad-export.json should have been rolled back.
|
|
rr = doRequest(dest, "GET", "/api/v1/workspaces", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("list workspaces: %d", rr.Code)
|
|
}
|
|
if strings.Contains(rr.Body.String(), `"slug":"PostExportTraversal"`) ||
|
|
strings.Contains(rr.Body.String(), `"name":"PostExportTraversal"`) {
|
|
t.Errorf("workspace was NOT rolled back after path-traversal reject; body=%s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestImportBundle_RollbackTombstonesAttachments pins Codex P1
|
|
// round 2 on PR #308: when the duplicate-manifest guard fires AFTER
|
|
// blobs have already been rehydrated, the rollback must tombstone
|
|
// every attachment row in the partial workspace (deleted_at set) so
|
|
// orphan-GC reclaims the blobs and per-user storage usage is
|
|
// correct. Without the cascade, the workspace was soft-deleted but
|
|
// the attachment rows stayed live, pinning blobs from GC.
|
|
//
|
|
// The test builds a real export bundle (so manifest entries match
|
|
// real blob bytes), then surgically appends a duplicate manifest.json
|
|
// at the end. Posting that to a fresh server triggers blob
|
|
// rehydration of the original bundle's entries before the duplicate
|
|
// guard fires.
|
|
func TestImportBundle_RollbackTombstonesAttachments(t *testing.T) {
|
|
// Source workspace: upload a real PNG so the bundle has a real
|
|
// blob entry the destination must rehydrate before hitting the
|
|
// duplicate at the end.
|
|
src, srcSlug := testServerWithAttachments(t)
|
|
rr := doMultipartUpload(src, srcSlug, "logo.png", realPNG())
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Real bundle from the source.
|
|
rr = doRequest(src, "GET", "/api/v1/workspaces/"+srcSlug+"/export?format=tar", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("export: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
realBundle := rr.Body.Bytes()
|
|
|
|
// Extract every tar entry from the real bundle, then re-emit them
|
|
// in order + inject a duplicate attachments/manifest.json AFTER
|
|
// every original entry. The duplicate fires after blob
|
|
// rehydration, exercising the cascade path.
|
|
gz, err := gzip.NewReader(bytes.NewReader(realBundle))
|
|
if err != nil {
|
|
t.Fatalf("gzip read: %v", err)
|
|
}
|
|
tr := tar.NewReader(gz)
|
|
|
|
var out bytes.Buffer
|
|
outGz := gzip.NewWriter(&out)
|
|
outTw := tar.NewWriter(outGz)
|
|
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("tar next: %v", err)
|
|
}
|
|
body, err := io.ReadAll(tr)
|
|
if err != nil {
|
|
t.Fatalf("read entry: %v", err)
|
|
}
|
|
if err := outTw.WriteHeader(&tar.Header{Name: hdr.Name, Mode: 0o644, Size: int64(len(body))}); err != nil {
|
|
t.Fatalf("write header: %v", err)
|
|
}
|
|
if _, err := outTw.Write(body); err != nil {
|
|
t.Fatalf("write body: %v", err)
|
|
}
|
|
}
|
|
gz.Close()
|
|
|
|
// Inject the duplicate manifest.json after every real entry.
|
|
dupe := []byte(`{"version":1,"entries":[]}`)
|
|
if err := outTw.WriteHeader(&tar.Header{Name: "attachments/manifest.json", Mode: 0o644, Size: int64(len(dupe))}); err != nil {
|
|
t.Fatalf("write dupe header: %v", err)
|
|
}
|
|
if _, err := outTw.Write(dupe); err != nil {
|
|
t.Fatalf("write dupe: %v", err)
|
|
}
|
|
outTw.Close()
|
|
outGz.Close()
|
|
|
|
// Post to a fresh server and confirm 400.
|
|
dest, _ := testServerWithAttachments(t)
|
|
req := httptest.NewRequest("POST", "/api/v1/workspaces/import?name=Tombstoned",
|
|
bytes.NewReader(out.Bytes()))
|
|
req.Header.Set("Content-Type", "application/gzip")
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
rr = httptest.NewRecorder()
|
|
dest.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("rollback test: status=%d, want 400; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "duplicate") {
|
|
t.Fatalf("expected duplicate-manifest error, got body=%s", rr.Body.String())
|
|
}
|
|
|
|
// Workspace must be gone from listings.
|
|
rr = doRequest(dest, "GET", "/api/v1/workspaces", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("list workspaces: %d", rr.Code)
|
|
}
|
|
if strings.Contains(rr.Body.String(), `"slug":"Tombstoned"`) || strings.Contains(rr.Body.String(), `"name":"Tombstoned"`) {
|
|
t.Errorf("workspace was NOT rolled back; listing body=%s", rr.Body.String())
|
|
}
|
|
|
|
// Critically: every attachment row that the partial-import
|
|
// rehydrated must now have deleted_at set so orphan-GC can
|
|
// reclaim the blobs. Querying the store directly because the
|
|
// workspace is gone from the API surface.
|
|
var live int
|
|
if err := dest.store.DB().QueryRow(
|
|
`SELECT COUNT(*) FROM attachments WHERE deleted_at IS NULL`,
|
|
).Scan(&live); err != nil {
|
|
t.Fatalf("count live attachments: %v", err)
|
|
}
|
|
if live != 0 {
|
|
t.Errorf("rollback left %d live attachment rows; expected 0 (all should be tombstoned)", live)
|
|
}
|
|
}
|
|
|
|
// TestIsSafeBundleEntryName covers isSafeBundleEntryName as a pure
|
|
// helper. Some inputs (NUL bytes, empty strings) can't be exercised
|
|
// through archive/tar — Go's tar.WriteHeader refuses to encode them
|
|
// — so we test the helper directly to keep the defense-in-depth
|
|
// guarantees verifiable.
|
|
func TestIsSafeBundleEntryName(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want bool
|
|
}{
|
|
{"normal export", "pad-export.json", true},
|
|
{"normal manifest", "attachments/manifest.json", true},
|
|
{"normal blob", "attachments/abc123.png", true},
|
|
{"single dot segment is fine", "./pad-export.json", true},
|
|
{"empty string", "", false},
|
|
{"absolute unix", "/etc/passwd", false},
|
|
{"absolute windows", "\\windows\\system32", false},
|
|
{"dot-dot prefix", "../etc/passwd", false},
|
|
{"dot-dot mid", "attachments/../../etc/passwd", false},
|
|
{"dot-dot only", "..", false},
|
|
{"NUL byte mid", "attachments/foo\x00.png", false},
|
|
{"backslash dot-dot", "attachments\\..\\..\\etc", false},
|
|
{"trailing dot-dot", "attachments/..", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := isSafeBundleEntryName(tc.in)
|
|
if got != tc.want {
|
|
t.Errorf("isSafeBundleEntryName(%q) = %v, want %v", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|