mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Both writeFile and editFile now use the same atomic write strategy: temp file in the same directory, write, rename. This ensures a failed write leaves the original file intact instead of truncated. editFile already used temp-and-rename but lost the original file's permissions because afero.TempFile creates with mode 0600. Both functions now Chmod after rename to preserve the original mode. writeFile also swallowed io.Copy errors (logged but returned HTTP 200). Fixed to return the error so the client knows the write failed.
1396 lines
35 KiB
Go
1396 lines
35 KiB
Go
package agentfiles_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"testing/iotest"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/agent/agentfiles"
|
|
"github.com/coder/coder/v2/agent/agentgit"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
type testFs struct {
|
|
afero.Fs
|
|
// intercept can return an error for testing when a call fails.
|
|
intercept func(call, file string) error
|
|
}
|
|
|
|
func newTestFs(base afero.Fs, intercept func(call, file string) error) *testFs {
|
|
return &testFs{
|
|
Fs: base,
|
|
intercept: intercept,
|
|
}
|
|
}
|
|
|
|
func (fs *testFs) Open(name string) (afero.File, error) {
|
|
if err := fs.intercept("open", name); err != nil {
|
|
return nil, err
|
|
}
|
|
return fs.Fs.Open(name)
|
|
}
|
|
|
|
func (fs *testFs) Create(name string) (afero.File, error) {
|
|
if err := fs.intercept("create", name); err != nil {
|
|
return nil, err
|
|
}
|
|
// Unlike os, afero lets you create files where directories already exist and
|
|
// lets you nest them underneath files, somehow.
|
|
stat, err := fs.Fs.Stat(name)
|
|
if err == nil && stat.IsDir() {
|
|
return nil, &os.PathError{
|
|
Op: "open",
|
|
Path: name,
|
|
Err: syscall.EISDIR,
|
|
}
|
|
}
|
|
stat, err = fs.Fs.Stat(filepath.Dir(name))
|
|
if err == nil && !stat.IsDir() {
|
|
return nil, &os.PathError{
|
|
Op: "open",
|
|
Path: name,
|
|
Err: syscall.ENOTDIR,
|
|
}
|
|
}
|
|
return fs.Fs.Create(name)
|
|
}
|
|
|
|
func (fs *testFs) MkdirAll(name string, mode os.FileMode) error {
|
|
if err := fs.intercept("mkdirall", name); err != nil {
|
|
return err
|
|
}
|
|
// Unlike os, afero lets you create directories where files already exist and
|
|
// lets you nest them underneath files somehow.
|
|
stat, err := fs.Fs.Stat(filepath.Dir(name))
|
|
if err == nil && !stat.IsDir() {
|
|
return &os.PathError{
|
|
Op: "mkdir",
|
|
Path: name,
|
|
Err: syscall.ENOTDIR,
|
|
}
|
|
}
|
|
stat, err = fs.Fs.Stat(name)
|
|
if err == nil && !stat.IsDir() {
|
|
return &os.PathError{
|
|
Op: "mkdir",
|
|
Path: name,
|
|
Err: syscall.ENOTDIR,
|
|
}
|
|
}
|
|
return fs.Fs.MkdirAll(name, mode)
|
|
}
|
|
|
|
func (fs *testFs) Rename(oldName, newName string) error {
|
|
if err := fs.intercept("rename", newName); err != nil {
|
|
return err
|
|
}
|
|
return fs.Fs.Rename(oldName, newName)
|
|
}
|
|
|
|
func TestReadFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
noPermsFilePath := filepath.Join(tmpdir, "no-perms")
|
|
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
|
if file == noPermsFilePath {
|
|
return os.ErrPermission
|
|
}
|
|
return nil
|
|
})
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
|
|
dirPath := filepath.Join(tmpdir, "a-directory")
|
|
err := fs.MkdirAll(dirPath, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
filePath := filepath.Join(tmpdir, "file")
|
|
err = afero.WriteFile(fs, filePath, []byte("content"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
imagePath := filepath.Join(tmpdir, "file.png")
|
|
err = afero.WriteFile(fs, imagePath, []byte("not really an image"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
limit int64
|
|
offset int64
|
|
bytes []byte
|
|
mimeType string
|
|
errCode int
|
|
error string
|
|
}{
|
|
{
|
|
name: "NoPath",
|
|
path: "",
|
|
errCode: http.StatusBadRequest,
|
|
error: "\"path\" is required",
|
|
},
|
|
{
|
|
name: "RelativePathDotSlash",
|
|
path: "./relative",
|
|
errCode: http.StatusBadRequest,
|
|
error: "file path must be absolute",
|
|
},
|
|
{
|
|
name: "RelativePath",
|
|
path: "also-relative",
|
|
errCode: http.StatusBadRequest,
|
|
error: "file path must be absolute",
|
|
},
|
|
{
|
|
name: "NegativeLimit",
|
|
path: filePath,
|
|
limit: -10,
|
|
errCode: http.StatusBadRequest,
|
|
error: "value is negative",
|
|
},
|
|
{
|
|
name: "NegativeOffset",
|
|
path: filePath,
|
|
offset: -10,
|
|
errCode: http.StatusBadRequest,
|
|
error: "value is negative",
|
|
},
|
|
{
|
|
name: "NonExistent",
|
|
path: filepath.Join(tmpdir, "does-not-exist"),
|
|
errCode: http.StatusNotFound,
|
|
error: "file does not exist",
|
|
},
|
|
{
|
|
name: "IsDir",
|
|
path: dirPath,
|
|
errCode: http.StatusBadRequest,
|
|
error: "not a file",
|
|
},
|
|
{
|
|
name: "NoPermissions",
|
|
path: noPermsFilePath,
|
|
errCode: http.StatusForbidden,
|
|
error: "permission denied",
|
|
},
|
|
{
|
|
name: "Defaults",
|
|
path: filePath,
|
|
bytes: []byte("content"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Limit1",
|
|
path: filePath,
|
|
limit: 1,
|
|
bytes: []byte("c"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Offset1",
|
|
path: filePath,
|
|
offset: 1,
|
|
bytes: []byte("ontent"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Limit1Offset2",
|
|
path: filePath,
|
|
limit: 1,
|
|
offset: 2,
|
|
bytes: []byte("n"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Limit7Offset0",
|
|
path: filePath,
|
|
limit: 7,
|
|
offset: 0,
|
|
bytes: []byte("content"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Limit100",
|
|
path: filePath,
|
|
limit: 100,
|
|
bytes: []byte("content"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Offset7",
|
|
path: filePath,
|
|
offset: 7,
|
|
bytes: []byte{},
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Offset100",
|
|
path: filePath,
|
|
offset: 100,
|
|
bytes: []byte{},
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "MimeTypePng",
|
|
path: imagePath,
|
|
bytes: []byte("not really an image"),
|
|
mimeType: "image/png",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("/read-file?path=%s&offset=%d&limit=%d", tt.path, tt.offset, tt.limit), nil)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
if tt.errCode != 0 {
|
|
got := &codersdk.Error{}
|
|
err := json.NewDecoder(w.Body).Decode(got)
|
|
require.NoError(t, err)
|
|
require.ErrorContains(t, got, tt.error)
|
|
require.Equal(t, tt.errCode, w.Code)
|
|
} else {
|
|
bytes, err := io.ReadAll(w.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.bytes, bytes)
|
|
require.Equal(t, tt.mimeType, w.Header().Get("Content-Type"))
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriteFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
noPermsFilePath := filepath.Join(tmpdir, "no-perms-file")
|
|
noPermsDirPath := filepath.Join(tmpdir, "no-perms-dir")
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
|
if file == noPermsFilePath || file == noPermsDirPath {
|
|
return os.ErrPermission
|
|
}
|
|
return nil
|
|
})
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
|
|
dirPath := filepath.Join(tmpdir, "directory")
|
|
err := fs.MkdirAll(dirPath, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
filePath := filepath.Join(tmpdir, "file")
|
|
err = afero.WriteFile(fs, filePath, []byte("content"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
notDirErr := "not a directory"
|
|
if runtime.GOOS == "windows" {
|
|
notDirErr = "cannot find the path"
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
bytes []byte
|
|
errCode int
|
|
error string
|
|
}{
|
|
{
|
|
name: "NoPath",
|
|
path: "",
|
|
errCode: http.StatusBadRequest,
|
|
error: "\"path\" is required",
|
|
},
|
|
{
|
|
name: "RelativePathDotSlash",
|
|
path: "./relative",
|
|
errCode: http.StatusBadRequest,
|
|
error: "file path must be absolute",
|
|
},
|
|
{
|
|
name: "RelativePath",
|
|
path: "also-relative",
|
|
errCode: http.StatusBadRequest,
|
|
error: "file path must be absolute",
|
|
},
|
|
{
|
|
name: "NonExistent",
|
|
path: filepath.Join(tmpdir, "/nested/does-not-exist"),
|
|
bytes: []byte("now it does exist"),
|
|
},
|
|
{
|
|
name: "IsDir",
|
|
path: dirPath,
|
|
errCode: http.StatusBadRequest,
|
|
error: "is a directory",
|
|
},
|
|
{
|
|
name: "IsNotDir",
|
|
path: filepath.Join(filePath, "file2"),
|
|
errCode: http.StatusBadRequest,
|
|
error: notDirErr,
|
|
},
|
|
{
|
|
name: "NoPermissionsFile",
|
|
path: noPermsFilePath,
|
|
errCode: http.StatusForbidden,
|
|
error: "permission denied",
|
|
},
|
|
{
|
|
name: "NoPermissionsDir",
|
|
path: filepath.Join(noPermsDirPath, "within-no-perm-dir"),
|
|
errCode: http.StatusForbidden,
|
|
error: "permission denied",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
reader := bytes.NewReader(tt.bytes)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("/write-file?path=%s", tt.path), reader)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
if tt.errCode != 0 {
|
|
got := &codersdk.Error{}
|
|
err := json.NewDecoder(w.Body).Decode(got)
|
|
require.NoError(t, err)
|
|
require.ErrorContains(t, got, tt.error)
|
|
require.Equal(t, tt.errCode, w.Code)
|
|
} else {
|
|
bytes, err := afero.ReadFile(fs, tt.path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.bytes, bytes)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriteFile_ReportsIOError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
|
|
tmpdir := os.TempDir()
|
|
path := filepath.Join(tmpdir, "write-io-error")
|
|
err := afero.WriteFile(fs, path, []byte("original"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
|
defer cancel()
|
|
|
|
// A reader that always errors simulates a failed body read
|
|
// (e.g. network interruption). The atomic write should leave
|
|
// the original file intact.
|
|
body := iotest.ErrReader(xerrors.New("simulated I/O error"))
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost,
|
|
fmt.Sprintf("/write-file?path=%s", path), body)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
|
got := &codersdk.Error{}
|
|
err = json.NewDecoder(w.Body).Decode(got)
|
|
require.NoError(t, err)
|
|
require.ErrorContains(t, got, "simulated I/O error")
|
|
|
|
// The original file must survive the failed write.
|
|
data, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "original", string(data))
|
|
}
|
|
|
|
func TestWriteFile_PreservesPermissions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("file permissions are not reliably supported on Windows")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
|
osFs := afero.NewOsFs()
|
|
api := agentfiles.NewAPI(logger, osFs, nil)
|
|
|
|
path := filepath.Join(dir, "script.sh")
|
|
err := afero.WriteFile(osFs, path, []byte("#!/bin/sh\necho hello\n"), 0o755)
|
|
require.NoError(t, err)
|
|
|
|
info, err := osFs.Stat(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
|
defer cancel()
|
|
|
|
// Overwrite the file with new content.
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost,
|
|
fmt.Sprintf("/write-file?path=%s", path),
|
|
bytes.NewReader([]byte("#!/bin/sh\necho world\n")))
|
|
api.Routes().ServeHTTP(w, r)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
data, err := afero.ReadFile(osFs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "#!/bin/sh\necho world\n", string(data))
|
|
|
|
info, err = osFs.Stat(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(0o755), info.Mode().Perm(),
|
|
"write_file should preserve the original file's permissions")
|
|
}
|
|
|
|
func TestEditFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
noPermsFilePath := filepath.Join(tmpdir, "no-perms-file")
|
|
failRenameFilePath := filepath.Join(tmpdir, "fail-rename")
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
|
if file == noPermsFilePath {
|
|
return &os.PathError{
|
|
Op: call,
|
|
Path: file,
|
|
Err: os.ErrPermission,
|
|
}
|
|
} else if file == failRenameFilePath && call == "rename" {
|
|
return xerrors.New("rename failed")
|
|
}
|
|
return nil
|
|
})
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
|
|
dirPath := filepath.Join(tmpdir, "directory")
|
|
err := fs.MkdirAll(dirPath, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
contents map[string]string
|
|
edits []workspacesdk.FileEdits
|
|
expected map[string]string
|
|
errCode int
|
|
errors []string
|
|
}{
|
|
{
|
|
name: "NoFiles",
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"must specify at least one file"},
|
|
},
|
|
{
|
|
name: "NoPath",
|
|
errCode: http.StatusBadRequest,
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errors: []string{"\"path\" is required"},
|
|
},
|
|
{
|
|
name: "RelativePathDotSlash",
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: "./relative",
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"file path must be absolute"},
|
|
},
|
|
{
|
|
name: "RelativePath",
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: "also-relative",
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"file path must be absolute"},
|
|
},
|
|
{
|
|
name: "NoEdits",
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "no-edits"),
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"must specify at least one edit"},
|
|
},
|
|
{
|
|
name: "NonExistent",
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "does-not-exist"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusNotFound,
|
|
errors: []string{"file does not exist"},
|
|
},
|
|
{
|
|
name: "IsDir",
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: dirPath,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"not a file"},
|
|
},
|
|
{
|
|
name: "NoPermissions",
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: noPermsFilePath,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusForbidden,
|
|
errors: []string{"permission denied"},
|
|
},
|
|
{
|
|
name: "FailRename",
|
|
contents: map[string]string{failRenameFilePath: "foo bar"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: failRenameFilePath,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusInternalServerError,
|
|
errors: []string{"rename failed"},
|
|
},
|
|
{
|
|
name: "Edit1",
|
|
contents: map[string]string{filepath.Join(tmpdir, "edit1"): "foo bar"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "edit1"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "edit1"): "bar bar"},
|
|
},
|
|
{
|
|
// When the second edit creates ambiguity (two "bar"
|
|
// occurrences), it should fail.
|
|
name: "EditEditAmbiguous",
|
|
contents: map[string]string{filepath.Join(tmpdir, "edit-edit"): "foo bar"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "edit-edit"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
{
|
|
Search: "bar",
|
|
Replace: "qux",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"matches 2 occurrences"},
|
|
// File should not be modified on error.
|
|
expected: map[string]string{filepath.Join(tmpdir, "edit-edit"): "foo bar"},
|
|
},
|
|
{
|
|
// With replace_all the cascading edit replaces
|
|
// both occurrences.
|
|
name: "EditEditReplaceAll",
|
|
contents: map[string]string{filepath.Join(tmpdir, "edit-edit-ra"): "foo bar"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "edit-edit-ra"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
{
|
|
Search: "bar",
|
|
Replace: "qux",
|
|
ReplaceAll: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "edit-edit-ra"): "qux qux"},
|
|
},
|
|
{
|
|
name: "Multiline",
|
|
contents: map[string]string{filepath.Join(tmpdir, "multiline"): "foo\nbar\nbaz\nqux"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "multiline"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "bar\nbaz",
|
|
Replace: "frob",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "multiline"): "foo\nfrob\nqux"},
|
|
},
|
|
{
|
|
name: "Multifile",
|
|
contents: map[string]string{
|
|
filepath.Join(tmpdir, "file1"): "file 1",
|
|
filepath.Join(tmpdir, "file2"): "file 2",
|
|
filepath.Join(tmpdir, "file3"): "file 3",
|
|
},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "file1"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "file",
|
|
Replace: "edited1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Path: filepath.Join(tmpdir, "file2"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "file",
|
|
Replace: "edited2",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Path: filepath.Join(tmpdir, "file3"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "file",
|
|
Replace: "edited3",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{
|
|
filepath.Join(tmpdir, "file1"): "edited1 1",
|
|
filepath.Join(tmpdir, "file2"): "edited2 2",
|
|
filepath.Join(tmpdir, "file3"): "edited3 3",
|
|
},
|
|
},
|
|
{
|
|
name: "TrailingWhitespace",
|
|
contents: map[string]string{filepath.Join(tmpdir, "trailing-ws"): "foo \nbar\t\t\nbaz"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "trailing-ws"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo\nbar\nbaz",
|
|
Replace: "replaced",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "trailing-ws"): "replaced"},
|
|
},
|
|
{
|
|
name: "TabsVsSpaces",
|
|
contents: map[string]string{filepath.Join(tmpdir, "tabs-vs-spaces"): "\tif true {\n\t\tfoo()\n\t}"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "tabs-vs-spaces"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
// Search uses spaces but file uses tabs.
|
|
Search: " if true {\n foo()\n }",
|
|
Replace: "\tif true {\n\t\tbar()\n\t}",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "tabs-vs-spaces"): "\tif true {\n\t\tbar()\n\t}"},
|
|
},
|
|
{
|
|
name: "DifferentIndentDepth",
|
|
contents: map[string]string{filepath.Join(tmpdir, "indent-depth"): "\t\t\tdeep()\n\t\t\tnested()"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "indent-depth"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
// Search has wrong indent depth (1 tab instead of 3).
|
|
Search: "\tdeep()\n\tnested()",
|
|
Replace: "\t\t\tdeep()\n\t\t\tchanged()",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "indent-depth"): "\t\t\tdeep()\n\t\t\tchanged()"},
|
|
},
|
|
{
|
|
name: "ExactMatchPreferred",
|
|
contents: map[string]string{filepath.Join(tmpdir, "exact-preferred"): "hello world"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "exact-preferred"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "hello world",
|
|
Replace: "goodbye world",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "exact-preferred"): "goodbye world"},
|
|
},
|
|
{
|
|
name: "NoMatchErrors",
|
|
contents: map[string]string{filepath.Join(tmpdir, "no-match"): "original content"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "no-match"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "this does not exist in the file",
|
|
Replace: "whatever",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"search string not found in file"},
|
|
// File should remain unchanged.
|
|
expected: map[string]string{filepath.Join(tmpdir, "no-match"): "original content"},
|
|
},
|
|
{
|
|
name: "AmbiguousExactMatch",
|
|
contents: map[string]string{filepath.Join(tmpdir, "ambig-exact"): "foo bar foo baz foo"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "ambig-exact"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "qux",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"matches 3 occurrences"},
|
|
expected: map[string]string{filepath.Join(tmpdir, "ambig-exact"): "foo bar foo baz foo"},
|
|
},
|
|
{
|
|
name: "ReplaceAllExact",
|
|
contents: map[string]string{filepath.Join(tmpdir, "ra-exact"): "foo bar foo baz foo"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "ra-exact"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "qux",
|
|
ReplaceAll: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "ra-exact"): "qux bar qux baz qux"},
|
|
},
|
|
{
|
|
name: "MixedWhitespaceMultiline",
|
|
contents: map[string]string{filepath.Join(tmpdir, "mixed-ws"): "func main() {\n\tresult := compute()\n\tfmt.Println(result)\n}"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "mixed-ws"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
// Search uses spaces, file uses tabs.
|
|
Search: " result := compute()\n fmt.Println(result)\n",
|
|
Replace: "\tresult := compute()\n\tlog.Println(result)\n",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "mixed-ws"): "func main() {\n\tresult := compute()\n\tlog.Println(result)\n}"},
|
|
},
|
|
{
|
|
name: "MultiError",
|
|
contents: map[string]string{
|
|
filepath.Join(tmpdir, "file8"): "file 8",
|
|
},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: noPermsFilePath,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "file",
|
|
Replace: "edited7",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Path: filepath.Join(tmpdir, "file8"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "file",
|
|
Replace: "edited8",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Path: filepath.Join(tmpdir, "file9"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "file",
|
|
Replace: "edited9",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{
|
|
filepath.Join(tmpdir, "file8"): "edited8 8",
|
|
},
|
|
// Higher status codes will override lower ones, so in this case the 404
|
|
// takes priority over the 403.
|
|
errCode: http.StatusNotFound,
|
|
errors: []string{
|
|
fmt.Sprintf("%s: permission denied", noPermsFilePath),
|
|
"file9: file does not exist",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
for path, content := range tt.contents {
|
|
err := afero.WriteFile(fs, path, []byte(content), 0o644)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
err := enc.Encode(workspacesdk.FileEditRequest{Files: tt.edits})
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
if tt.errCode != 0 {
|
|
got := &codersdk.Error{}
|
|
err := json.NewDecoder(w.Body).Decode(got)
|
|
require.NoError(t, err)
|
|
for _, error := range tt.errors {
|
|
require.ErrorContains(t, got, error)
|
|
}
|
|
require.Equal(t, tt.errCode, w.Code)
|
|
} else {
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
for path, expect := range tt.expected {
|
|
b, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, expect, string(b))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEditFiles_PreservesPermissions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("file permissions are not reliably supported on Windows")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
|
osFs := afero.NewOsFs()
|
|
api := agentfiles.NewAPI(logger, osFs, nil)
|
|
|
|
path := filepath.Join(dir, "script.sh")
|
|
err := afero.WriteFile(osFs, path, []byte("#!/bin/sh\necho hello\n"), 0o755)
|
|
require.NoError(t, err)
|
|
|
|
// Sanity-check the initial mode.
|
|
info, err := osFs.Stat(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
|
defer cancel()
|
|
|
|
body := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{
|
|
{
|
|
Path: path,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "hello",
|
|
Replace: "world",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
err = enc.Encode(body)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Verify content was updated.
|
|
data, err := afero.ReadFile(osFs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "#!/bin/sh\necho world\n", string(data))
|
|
|
|
// Verify permissions are preserved after the
|
|
// temp-file-and-rename cycle.
|
|
info, err = osFs.Stat(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(0o755), info.Mode().Perm(),
|
|
"edit_files should preserve the original file's permissions")
|
|
}
|
|
|
|
func TestHandleWriteFile_ChatHeaders_UpdatesPathStore(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pathStore := agentgit.NewPathStore()
|
|
logger := slogtest.Make(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, pathStore)
|
|
|
|
testPath := filepath.Join(os.TempDir(), "test.txt")
|
|
|
|
chatID := uuid.New()
|
|
ancestorID := uuid.New()
|
|
ancestorJSON, _ := json.Marshal([]string{ancestorID.String()})
|
|
|
|
body := strings.NewReader("hello world")
|
|
req := httptest.NewRequest(http.MethodPost, "/write-file?path="+testPath, body)
|
|
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
|
req.Header.Set(workspacesdk.CoderAncestorChatIDsHeader, string(ancestorJSON))
|
|
|
|
rr := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Post("/write-file", api.HandleWriteFile)
|
|
r.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
// Verify PathStore was updated for both chat and ancestor.
|
|
paths := pathStore.GetPaths(chatID)
|
|
require.Equal(t, []string{testPath}, paths)
|
|
|
|
ancestorPaths := pathStore.GetPaths(ancestorID)
|
|
require.Equal(t, []string{testPath}, ancestorPaths)
|
|
}
|
|
|
|
func TestHandleWriteFile_NoChatHeaders_NoPathStoreUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pathStore := agentgit.NewPathStore()
|
|
logger := slogtest.Make(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, pathStore)
|
|
|
|
testPath := filepath.Join(os.TempDir(), "test.txt")
|
|
|
|
body := strings.NewReader("hello world")
|
|
req := httptest.NewRequest(http.MethodPost, "/write-file?path="+testPath, body)
|
|
|
|
rr := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Post("/write-file", api.HandleWriteFile)
|
|
r.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
// PathStore should be globally empty since no chat headers were set.
|
|
require.Equal(t, 0, pathStore.Len())
|
|
}
|
|
|
|
func TestHandleWriteFile_Failure_NoPathStoreUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pathStore := agentgit.NewPathStore()
|
|
logger := slogtest.Make(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, pathStore)
|
|
|
|
chatID := uuid.New()
|
|
|
|
// Write to a relative path (should fail with 400).
|
|
body := strings.NewReader("hello world")
|
|
req := httptest.NewRequest(http.MethodPost, "/write-file?path=relative/path.txt", body)
|
|
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
|
|
|
rr := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Post("/write-file", api.HandleWriteFile)
|
|
r.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, rr.Code)
|
|
|
|
// PathStore should NOT be updated on failure.
|
|
paths := pathStore.GetPaths(chatID)
|
|
require.Empty(t, paths)
|
|
}
|
|
|
|
func TestHandleEditFiles_ChatHeaders_UpdatesPathStore(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pathStore := agentgit.NewPathStore()
|
|
logger := slogtest.Make(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, pathStore)
|
|
|
|
testPath := filepath.Join(os.TempDir(), "test.txt")
|
|
|
|
// Create the file first.
|
|
require.NoError(t, afero.WriteFile(fs, testPath, []byte("hello"), 0o644))
|
|
|
|
chatID := uuid.New()
|
|
editReq := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{
|
|
{
|
|
Path: testPath,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{Search: "hello", Replace: "world"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
body, _ := json.Marshal(editReq)
|
|
req := httptest.NewRequest(http.MethodPost, "/edit-files", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
|
|
|
rr := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Post("/edit-files", api.HandleEditFiles)
|
|
r.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
paths := pathStore.GetPaths(chatID)
|
|
require.Equal(t, []string{testPath}, paths)
|
|
}
|
|
|
|
func TestHandleEditFiles_Failure_NoPathStoreUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pathStore := agentgit.NewPathStore()
|
|
logger := slogtest.Make(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, pathStore)
|
|
|
|
chatID := uuid.New()
|
|
|
|
// Edit a non-existent file (should fail with 404).
|
|
editReq := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{
|
|
{
|
|
Path: "/nonexistent/file.txt",
|
|
Edits: []workspacesdk.FileEdit{
|
|
{Search: "hello", Replace: "world"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
body, _ := json.Marshal(editReq)
|
|
req := httptest.NewRequest(http.MethodPost, "/edit-files", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
|
|
|
rr := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Post("/edit-files", api.HandleEditFiles)
|
|
r.ServeHTTP(rr, req)
|
|
|
|
require.NotEqual(t, http.StatusOK, rr.Code)
|
|
|
|
// PathStore should NOT be updated on failure.
|
|
paths := pathStore.GetPaths(chatID)
|
|
require.Empty(t, paths)
|
|
}
|
|
|
|
func TestReadFileLines(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
noPermsFilePath := filepath.Join(tmpdir, "no-perms-lines")
|
|
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
|
if file == noPermsFilePath {
|
|
return os.ErrPermission
|
|
}
|
|
return nil
|
|
})
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
|
|
dirPath := filepath.Join(tmpdir, "a-directory-lines")
|
|
err := fs.MkdirAll(dirPath, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
emptyFilePath := filepath.Join(tmpdir, "empty-file")
|
|
err = afero.WriteFile(fs, emptyFilePath, []byte(""), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
basicFilePath := filepath.Join(tmpdir, "basic-file")
|
|
err = afero.WriteFile(fs, basicFilePath, []byte("line1\nline2\nline3"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
longLine := string(bytes.Repeat([]byte("x"), 1025))
|
|
longLineFilePath := filepath.Join(tmpdir, "long-line-file")
|
|
err = afero.WriteFile(fs, longLineFilePath, []byte(longLine), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
largeFilePath := filepath.Join(tmpdir, "large-file")
|
|
err = afero.WriteFile(fs, largeFilePath, bytes.Repeat([]byte("x"), 1<<20+1), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
offset int64
|
|
limit int64
|
|
expSuccess bool
|
|
expError string
|
|
expContent string
|
|
expTotal int
|
|
expRead int
|
|
expSize int64
|
|
// useCodersdk is set for cases where the handler returns
|
|
// codersdk.Response (query param validation) instead of ReadFileLinesResponse.
|
|
useCodersdk bool
|
|
}{
|
|
{
|
|
name: "NoPath",
|
|
path: "",
|
|
useCodersdk: true,
|
|
expError: "is required",
|
|
},
|
|
{
|
|
name: "RelativePath",
|
|
path: "relative/path",
|
|
expError: "file path must be absolute",
|
|
},
|
|
{
|
|
name: "NonExistent",
|
|
path: filepath.Join(tmpdir, "does-not-exist"),
|
|
expError: "file does not exist",
|
|
},
|
|
{
|
|
name: "IsDir",
|
|
path: dirPath,
|
|
expError: "not a file",
|
|
},
|
|
{
|
|
name: "NoPermissions",
|
|
path: noPermsFilePath,
|
|
expError: "permission denied",
|
|
},
|
|
{
|
|
name: "EmptyFile",
|
|
path: emptyFilePath,
|
|
expSuccess: true,
|
|
expTotal: 0,
|
|
expRead: 0,
|
|
expSize: 0,
|
|
},
|
|
{
|
|
name: "BasicRead",
|
|
path: basicFilePath,
|
|
expSuccess: true,
|
|
expContent: "1\tline1\n2\tline2\n3\tline3",
|
|
expTotal: 3,
|
|
expRead: 3,
|
|
expSize: int64(len("line1\nline2\nline3")),
|
|
},
|
|
{
|
|
name: "Offset2",
|
|
path: basicFilePath,
|
|
offset: 2,
|
|
expSuccess: true,
|
|
expContent: "2\tline2\n3\tline3",
|
|
expTotal: 3,
|
|
expRead: 2,
|
|
expSize: int64(len("line1\nline2\nline3")),
|
|
},
|
|
{
|
|
name: "Limit1",
|
|
path: basicFilePath,
|
|
limit: 1,
|
|
expSuccess: true,
|
|
expContent: "1\tline1",
|
|
expTotal: 3,
|
|
expRead: 1,
|
|
expSize: int64(len("line1\nline2\nline3")),
|
|
},
|
|
{
|
|
name: "Offset2Limit1",
|
|
path: basicFilePath,
|
|
offset: 2,
|
|
limit: 1,
|
|
expSuccess: true,
|
|
expContent: "2\tline2",
|
|
expTotal: 3,
|
|
expRead: 1,
|
|
expSize: int64(len("line1\nline2\nline3")),
|
|
},
|
|
{
|
|
name: "OffsetBeyondFile",
|
|
path: basicFilePath,
|
|
offset: 100,
|
|
expError: "offset 100 is beyond the file length of 3 lines",
|
|
},
|
|
{
|
|
name: "LongLineTruncation",
|
|
path: longLineFilePath,
|
|
expSuccess: true,
|
|
expContent: "1\t" + string(bytes.Repeat([]byte("x"), 1024)) + "... [truncated]",
|
|
expTotal: 1,
|
|
expRead: 1,
|
|
expSize: 1025,
|
|
},
|
|
{
|
|
name: "LargeFile",
|
|
path: largeFilePath,
|
|
expError: "exceeds the maximum",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("/read-file-lines?path=%s&offset=%d&limit=%d", tt.path, tt.offset, tt.limit), nil)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
if tt.useCodersdk {
|
|
// Query param validation errors return codersdk.Response.
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), tt.expError)
|
|
return
|
|
}
|
|
|
|
var resp agentfiles.ReadFileLinesResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
|
|
if tt.expSuccess {
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.True(t, resp.Success)
|
|
require.Equal(t, tt.expContent, resp.Content)
|
|
require.Equal(t, tt.expTotal, resp.TotalLines)
|
|
require.Equal(t, tt.expRead, resp.LinesRead)
|
|
require.Equal(t, tt.expSize, resp.FileSize)
|
|
} else {
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.False(t, resp.Success)
|
|
require.Contains(t, resp.Error, tt.expError)
|
|
}
|
|
})
|
|
}
|
|
}
|