This commit is contained in:
Gabe Farrell 2024-06-15 12:36:34 -04:00
commit c05e6d11e8
9 changed files with 1765 additions and 0 deletions

532
action/actions.go Normal file
View file

@ -0,0 +1,532 @@
package action
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
"time"
"github.com/urfave/cli/v2"
"gopkg.in/yaml.v3"
)
func CreateApplication(cfgPath string) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
if ctx.NArg() != 1 {
return errors.New("create app must have exactly one argument")
}
if !valid(ctx.Args().Get(0)) {
return errors.New("application name must only contain letters, numbers, dashes and underscores")
}
if _, err := os.Stat(AppPath(cfgPath, ctx.Args().Get(0))); err == nil {
return errors.New("application already exists")
}
err := WriteAppFiles(cfgPath, &AppInfo{
Version: "1",
Name: ctx.Args().Get(0),
Description: ctx.String("description"),
Host: ctx.String("host"),
})
if err != nil {
return err
}
fmt.Printf("Created application %s\n", ctx.Args().Get(0))
return nil
}
}
func CreateRequest(cfgPath string) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
if ctx.NArg() < 1 || ctx.NArg() > 1 {
return errors.New("create req must have exactly one argument")
}
app, err := getApp(cfgPath, ctx)
if err != nil {
return err
}
reqName := ctx.Args().Get(0)
if !valid(app) || !appExists(cfgPath, app) {
return errors.New("application does not exist")
}
if !valid(reqName) {
return errors.New("request name must only contain letters, numbers, dashes and underscores")
}
path := ReqPath(cfgPath, app, reqName)
if _, err := os.Stat(path); err == nil {
return errors.New("request already exists")
}
if err := WriteRequestFiles(cfgPath, app, &RequestInfo{
Version: "1",
Name: reqName,
Description: ctx.String("description"),
Method: ctx.String("method"),
Path: ctx.String("path"),
Headers: ctx.StringSlice("header"),
Body: ctx.String("body"),
}); err != nil {
return err
}
fmt.Printf("Created request %s\n", reqName)
return nil
}
}
func EditApplication(cfgPath string) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
if ctx.NArg() != 1 {
return errors.New("edit app must have exactly one argument")
}
if !valid(ctx.Args().Get(0)) {
return errors.New("application name is invalid")
}
contents, err := os.ReadFile(AppInfoFilePath(cfgPath, ctx.Args().Get(0)))
if err != nil {
return errors.New("application does not exist")
}
appinfo := new(AppInfo)
err = yaml.Unmarshal(contents, appinfo)
if err != nil {
return errors.New("failed to read application info")
}
if ctx.String("description") != "" {
appinfo.Description = ctx.String("description")
}
if ctx.String("host") != "" {
appinfo.Host = ctx.String("host")
}
err = WriteAppFiles(cfgPath, appinfo)
if err != nil {
return err
}
return nil
}
}
func EditRequest(cfgPath string) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
if ctx.NArg() < 1 || ctx.NArg() > 1 {
return errors.New("create req must have exactly one argument")
}
app, err := getApp(cfgPath, ctx)
if err != nil {
return err
}
reqName := ctx.Args().Get(0)
if !valid(app) || !appExists(cfgPath, app) {
return errors.New("application does not exist")
}
if !valid(reqName) {
return errors.New("request name is invalid")
}
path := ReqPath(cfgPath, app, reqName)
contents, err := os.ReadFile(path)
if err != nil {
return errors.New("request does not exists")
}
reqinfo := new(RequestInfo)
err = yaml.Unmarshal(contents, reqinfo)
if err != nil {
return errors.New("request info is malformed or corrupted")
}
if ctx.String("description") != "" {
reqinfo.Description = ctx.String("description")
}
if ctx.String("method") != "" {
reqinfo.Method = ctx.String("method")
}
if ctx.String("path") != "" {
reqinfo.Path = ctx.String("path")
}
if ctx.String("body") != "" {
reqinfo.Body = ctx.String("body")
}
if ctx.StringSlice("header") != nil {
reqinfo.Headers = ctx.StringSlice("header")
}
if err := WriteRequestFiles(cfgPath, app, reqinfo); err != nil {
return err
}
return nil
}
}
func DeleteApplication(cfgPath string) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
if ctx.NArg() < 1 || ctx.NArg() > 1 {
return errors.New("delete app must have exactly one argument")
}
app := ctx.Args().Get(0)
if !valid(app) {
return errors.New("application name is invalid")
}
_path := AppPath(cfgPath, app)
files, err := os.ReadDir(_path)
if err != nil {
return errors.New("application does not exist")
}
fmt.Printf(
"You are about to delete the application %s and %d associated request(s).\nThis action cannot be undone.\n",
app,
len(files)-1, // -1 because .appinfo isnt a req
)
if ctx.Bool("confirm") || ConfirmPrompt() {
err := os.RemoveAll(_path)
if err != nil {
return err
}
fmt.Print("application " + app + " has been deleted")
return nil
}
fmt.Println("delete aborted")
return nil
}
}
func DeleteRequest(cfgPath string) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
if ctx.NArg() < 1 || ctx.NArg() > 1 {
return errors.New("delete request must have exactly one argument")
}
app, err := getApp(cfgPath, ctx)
if err != nil {
return err
}
reqName := ctx.Args().Get(0)
if !valid(reqName) {
return errors.New("request name is invalid")
}
if !valid(app) || !appExists(cfgPath, app) {
return errors.New("application does not exist")
}
_path := ReqPath(cfgPath, app, reqName)
if _, err := os.Stat(_path); err != nil {
return errors.New("request does not exist")
}
fmt.Printf(
"You are about to delete the request %s in application %s.\nThis action cannot be undone.\n",
reqName,
app,
)
if ctx.Bool("confirm") || ConfirmPrompt() {
err := os.Remove(_path)
if err != nil {
return err
}
fmt.Print("request " + reqName + " has been deleted")
return nil
}
fmt.Println("delete aborted")
return nil
}
}
func Switch(cfgPath string) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
if ctx.NArg() < 1 {
return errors.New("expected argument")
}
if ctx.NArg() > 1 {
return errors.New("expected exactly one argument")
}
app := ctx.Args().Get(0)
if !valid(app) {
return errors.New("application does not exist")
}
if _, err := os.Stat(AppPath(cfgPath, app)); err != nil {
return errors.New("application does not exist")
}
err := os.WriteFile(path.Join(cfgPath, "current_app"), []byte(ctx.Args().First()), 0700)
if err != nil {
return err
}
fmt.Print(app)
return nil
}
}
func ListApplications(cfgPath string) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
_path := AppPath(cfgPath, "")
dirs, err := os.ReadDir(_path)
if err != nil {
return err
}
var apps string
for _, dir := range dirs {
appinfo := new(AppInfo)
contents, err := os.ReadFile(AppInfoFilePath(cfgPath, dir.Name()))
if err != nil {
return errors.New("failed to read application info")
}
err = yaml.Unmarshal(contents, appinfo)
if err != nil {
return errors.New(".appinfo is malformed or corrupted")
}
if appinfo.Description == "" {
apps += appinfo.Name + "\n"
} else {
apps += appinfo.Name + ": " + appinfo.Description + "\n"
}
}
fmt.Print(apps)
return nil
}
}
func ListRequests(cfgPath string) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
app, err := getApp(cfgPath, ctx)
if err != nil {
return err
}
_path := AppPath(cfgPath, app)
files, err := os.ReadDir(_path)
if err != nil {
return err
}
reqs := ""
for _, file := range files {
if file.Name() == ".appinfo" {
continue
}
reqinfo := new(RequestInfo)
contents, err := os.ReadFile(path.Join(AppPath(cfgPath, app), file.Name()))
if err != nil {
return errors.New("failed to read request info")
}
err = yaml.Unmarshal(contents, reqinfo)
if err != nil {
return errors.New("request file is malformed or corrupted")
}
if reqinfo.Description == "" {
reqs += reqinfo.Name + "\n"
} else {
reqs += reqinfo.Name + ": " + reqinfo.Description + "\n"
}
}
fmt.Print(reqs)
return nil
}
}
func ListAll(cfgPath string) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
apps, err := os.ReadDir(AppPath(cfgPath, ""))
if err != nil {
return err
}
output := ""
if len(apps) < 1 {
fmt.Println("No applications or requests")
return nil
}
for _, app := range apps {
reqfiles, err := os.ReadDir(AppPath(cfgPath, app.Name()))
if err != nil {
return err
}
if len(reqfiles) <= 1 {
output += app.Name() + "\n"
} else {
output += app.Name() + ":\n"
}
for _, reqfile := range reqfiles {
if reqfile.Name() == ".appinfo" {
continue
}
reqinfo := new(RequestInfo)
contents, err := os.ReadFile(path.Join(AppPath(cfgPath, app.Name()), reqfile.Name()))
if err != nil {
return errors.New("failed to read request info")
}
err = yaml.Unmarshal(contents, reqinfo)
if err != nil {
return errors.New("request file is malformed or corrupted")
}
if reqinfo.Description == "" {
output += "\t- " + reqinfo.Name + "\n"
} else {
output += "\t- " + reqinfo.Name + ": " + reqinfo.Description + "\n"
}
}
}
fmt.Print(output)
return nil
}
}
func InfoApplication(cfgPath string) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
if ctx.NArg() < 1 {
return errors.New("expected argument")
}
if ctx.NArg() > 1 {
return errors.New("expected exactly one argument")
}
if !valid(ctx.Args().Get(0)) {
return errors.New("application name is invalid")
}
contents, err := os.ReadFile(AppInfoFilePath(cfgPath, ctx.Args().Get(0)))
if err != nil {
return errors.New("failed to read app info")
}
appinfo := new(AppInfo)
err = yaml.Unmarshal(contents, appinfo)
if err != nil {
return errors.New("failed to read app info")
}
fmt.Printf("%s:\n\tDescription: %s\n\tHost: %s\n", appinfo.Name, appinfo.Description, appinfo.Host)
return nil
}
}
func InfoRequest(cfgPath string) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
if ctx.NArg() < 1 {
return errors.New("expected argument")
}
if ctx.NArg() > 1 {
return errors.New("expected exactly one argument")
}
app, err := getApp(cfgPath, ctx)
if err != nil {
return err
}
if !valid(app) {
return errors.New("application name is invalid")
}
if !valid(ctx.Args().Get(0)) {
return errors.New("request name is invalid")
}
contents, err := os.ReadFile(ReqPath(cfgPath, app, ctx.Args().Get(0)))
if err != nil {
return errors.New("failed to read request info")
}
fileLines := strings.Split(string(contents), "\n")
fmt.Println(ctx.Args().Get(0) + ":\n\t" + strings.Join(fileLines[2:len(fileLines)-1], "\n\t"))
return nil
}
}
type VerboseCallResponse struct {
Request string `yaml:"Request"`
RequestBody string `yaml:"RequestBody"`
Headers map[string]string `yaml:"Headers"`
Latency string `yaml:"Latency"`
Status string `yaml:"Status"`
ResponseBody string `yaml:"ResponseBody"`
}
func Call(cfgPath string, httpClient http.Client) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
if ctx.NArg() < 1 {
return errors.New("expected argument")
}
if ctx.NArg() > 1 {
return errors.New("expected exactly one argument")
}
app, err := getApp(cfgPath, ctx)
if err != nil {
return err
}
if !valid(app) {
return errors.New("application name is invalid")
}
if !valid(ctx.Args().Get(0)) {
return errors.New("request name is invalid")
}
// retrieve app information
contents, err := os.ReadFile(AppInfoFilePath(cfgPath, app))
if err != nil {
return errors.New("failed to retrieve app information")
}
appinfo := new(AppInfo)
err = yaml.Unmarshal(contents, appinfo)
if err != nil {
return errors.New("appinfo file is malformed or corrupted")
}
// retrieve request information
contents, err = os.ReadFile(ReqPath(cfgPath, app, ctx.Args().Get(0)))
if err != nil {
return errors.New("failed to retrieve request information")
}
reqinfo := new(RequestInfo)
err = yaml.Unmarshal(contents, reqinfo)
if err != nil {
return errors.New("request file is malformed or corrupted")
}
body := bytes.NewBuffer([]byte(reqinfo.Body))
req, err := http.NewRequest(reqinfo.Method, appinfo.Host+reqinfo.Path, body)
if err != nil {
return errors.New("failed to create web request")
}
for _, header := range reqinfo.Headers {
h := strings.Split(header, ": ")
if len(h) < 2 {
return errors.New("malformed header(s)")
}
req.Header.Add(h[0], h[1])
}
if ctx.Bool("no-redirect") {
httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }
}
t1 := time.Now()
resp, err := httpClient.Do(req)
t2 := time.Now()
if err != nil {
return errors.New("failed to send web request")
}
defer resp.Body.Close()
if ctx.Bool("fail") && resp.StatusCode >= 400 {
return errors.New("")
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return errors.New("failed to read response body")
}
if !ctx.Bool("verbose") {
fmt.Print(string(respBody) + "\n")
} else if req.Method == "GET" {
var out VerboseCallResponse
out.Request = req.Method + " " + req.URL.String()
out.RequestBody = reqinfo.Body
out.Headers = make(map[string]string)
for _, header := range reqinfo.Headers {
k := strings.Split(header, ": ")[0]
v := strings.Split(header, ": ")[1]
out.Headers[k] = v
}
out.Latency = fmt.Sprintf("%v", t2.Sub(t1))
out.Status = resp.Status
out.ResponseBody = string(respBody)
o, err := yaml.Marshal(out)
if err != nil {
return errors.New("failed to generate command output")
}
fmt.Print(string(o))
}
return nil
}
}

618
action/actions_test.go Normal file
View file

@ -0,0 +1,618 @@
package action_test
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path"
"strings"
"testing"
"github.com/gabehf/sp9rk/action"
"github.com/gabehf/sp9rk/app"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v2"
"gopkg.in/yaml.v3"
)
// pretty much all the other tests rely on this test working...
// i know its bad practice but I dont want to manually create every mock app and request files
// manually every time i make the test.
//
// Basically, if this test fails, fix it FIRST.
func TestWriteFiles(t *testing.T) {
cfgPath := path.Join("TestActionWriteFiles", ".sp9rk", "tests")
err := action.WriteAppFiles(cfgPath, &action.AppInfo{
Version: "1",
Name: "TestApp",
Description: "test app",
Host: "localhost",
})
assert.NoError(t, err, "FIX FIRST: failed to write app files")
p := action.AppPath(cfgPath, "TestApp")
_, err = os.Stat(p)
assert.NoError(t, err, "failed to create app directory")
_, err = os.Stat(path.Join(p, ".appinfo"))
assert.NoError(t, err, "failed to create .appinfo file")
err = action.WriteRequestFiles(cfgPath, "TestApp", &action.RequestInfo{
Version: "1",
Name: "MyReq",
Description: "my request",
})
assert.NoError(t, err, "FIX FIRST: failed to write request file")
_, err = os.Stat(path.Join(p, "MyReq.yml"))
assert.NoError(t, err, "failed to stat request file")
os.RemoveAll("TestActionWriteFiles")
}
func TestActionCreateApplication(t *testing.T) {
cfgPath := path.Join("TestActionCreateApplication", ".sp9rk", "tests")
app := app.New(cfgPath, http.Client{})
assert.Error(
t,
RunWithArgs(app, "create", "app"),
"create app should fail with no args",
)
assert.Error(
t,
RunWithArgs(app, "create", "app", "-u", "hostname123", "../malicious/path"),
"create app should fail invalid name",
)
assert.Error(
t,
RunWithArgs(app, "create", "app", "-u", "hostname123", "TestApp", "secondArgument"),
"create app should fail with >1 args",
)
assert.NoError(
t,
RunWithArgs(app, "create", "app", "-u", "hostname123", "TestApp"),
"create app should succeed with 1 args",
)
assert.Error(
t,
RunWithArgs(app, "create", "app", "-u", "hostname123", "TestApp"),
"create app should fail when app already exists",
)
// cleanup
os.RemoveAll("TestActionCreateApplication")
}
func TestActionCreateRequest(t *testing.T) {
cfgPath := path.Join("TestActionCreateRequest", ".sp9rk", "tests")
// simulate apps being created
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
})
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestAppTwo",
})
// tests
app := app.New(cfgPath, http.Client{})
assert.Error(t, RunWithArgs(app, "create", "req"), "create req should fail with no args")
assert.Error(t, RunWithArgs(app, "create", "req", "TestReq"), "create req should fail with no default app")
os.WriteFile(path.Join(cfgPath, "current_app"), []byte("TestApp"), 0700)
assert.NoError(t, RunWithArgs(app, "create", "req", "TestReq"), "create req should succeed with default app")
assert.Error(t, RunWithArgs(app, "create", "req", "TestReq", "SecondArg"), "create req should fail with >1 args")
assert.NoError(t, RunWithArgs(app, "create", "req", "-a", "TestAppTwo", "TestReq"), "create req should succeed with specified app")
assert.Error(t, RunWithArgs(app, "create", "req", "-a", "TestAppThree", "TestReq"), "create req should fail with unknown app")
assert.Error(t, RunWithArgs(app, "create", "req", "../malicious/path"), "create req should fail invalid name")
assert.Error(t, RunWithArgs(app, "create", "req", "-a", "TestAppTwo", "TestReq"), "create req should fail if it already exists")
contents, _ := os.ReadFile(path.Join(action.AppPath(cfgPath, "TestApp"), "TestReq.yml"))
reqinfo := new(action.RequestInfo)
err := yaml.Unmarshal(contents, reqinfo)
assert.NoError(t, err, "contents should be YAML")
assert.EqualValues(t, reqinfo.Name, "TestReq")
assert.EqualValues(t, reqinfo.Description, "")
assert.EqualValues(t, reqinfo.Method, "GET")
assert.EqualValues(t, reqinfo.Path, "/")
assert.EqualValues(t, reqinfo.Body, "")
assert.Empty(t, reqinfo.Headers)
// flag tests
assert.NoError(
t,
RunWithArgs(app, "create", "req",
"-d", "description",
"-X", "POST",
"-p", "/path",
"-b", "request body",
"-H", "X-Header-One: 1",
"-H", "X-Header-Two: 2",
"TestReqTwo",
),
"create req should succeed with flags",
)
contents, _ = os.ReadFile(path.Join(action.AppPath(cfgPath, "TestApp"), "TestReqTwo.yml"))
reqinfo = new(action.RequestInfo)
err = yaml.Unmarshal(contents, reqinfo)
assert.NoError(t, err, "contents should be YAML")
if reqinfo.Headers == nil || len(reqinfo.Headers) < 2 {
assert.FailNow(t, "file contents were not parsed successfully")
}
assert.EqualValues(t, reqinfo.Name, "TestReqTwo")
assert.EqualValues(t, reqinfo.Description, "description")
assert.EqualValues(t, reqinfo.Method, "POST")
assert.EqualValues(t, reqinfo.Path, "/path")
assert.EqualValues(t, reqinfo.Body, "request body")
assert.EqualValues(t, reqinfo.Headers[0], "X-Header-One: 1")
assert.EqualValues(t, reqinfo.Headers[1], "X-Header-Two: 2")
// cleanup
os.RemoveAll("TestActionCreateRequest")
}
func TestActionSwitch(t *testing.T) {
cfgPath := path.Join("TestActionSwitch", ".sp9rk", "tests")
app := app.New(cfgPath, http.Client{})
assert.Error(t, RunWithArgs(app, "switch"), "switch should fail with no args")
assert.Error(t, RunWithArgs(app, "switch", "TestApp"), "switch should fail with no apps existing")
// simulate apps being created and one set as default
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
})
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestAppTwo",
})
os.WriteFile(path.Join(cfgPath, "current_app"), []byte("TestApp"), 0700)
// tests
assert.Error(t, RunWithArgs(app, "switch"), "switch should fail with no args")
output, err := captureOutput(RunWithArgs, app, "switch", "TestApp")
assert.NoError(t, err, "switch should NOT fail with existing app")
assert.Equal(t, "TestApp", output, "switch output should be equal to new app name on success")
assert.Error(t, RunWithArgs(app, "switch", "../malicious/path"), "switch should fail invalid name")
assert.Error(t, RunWithArgs(app, "switch", "TestApp", "somethingElse"), "switch should fail with >1 arg")
assert.Error(t, RunWithArgs(app, "switch", "NotRealApp"), "switch should fail with unknown app")
os.RemoveAll("TestActionSwitch")
}
func TestActionDeleteApp(t *testing.T) {
cfgPath := path.Join("TestActionDeleteApp", ".sp9rk", "tests")
// simulate apps being created
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
Description: "this is a very cool app that does cool stuff",
Host: "http://localhost:3000",
})
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestAppTwo",
Description: "this is another very cool app that does cool stuff",
Host: "http://localhost:3001",
})
// tests
app := app.New(cfgPath, http.Client{})
assert.Error(t, RunWithArgs(app, "delete", "app"), "delete app should fail with no args")
assert.NoError(t, RunWithArgs(app, "delete", "app", "--confirm", "TestApp"), "delete app should NOT fail with existing app")
assert.Error(t, RunWithArgs(app, "delete", "app", "../malicious/path"), "delete app should fail invalid name")
assert.Error(t, RunWithArgs(app, "delete", "app", "TestAppTwo", "somethingElse"), "delete app should fail with >1 arg")
assert.Error(t, RunWithArgs(app, "delete", "app", "NotRealApp"), "delete app should fail with unknown app")
assert.NoError(t, RunWithArgs(app, "delete", "app", "--confirm", "TestAppTwo"), "delete app should NOT fail with existing app")
assert.Error(t, RunWithArgs(app, "delete", "app", "TestAppTwo"), "delete app should fail with deleted app")
os.RemoveAll("TestActionDeleteApp")
}
func TestActionDeleteRequest(t *testing.T) {
cfgPath := path.Join("TestActionDeleteRequest", ".sp9rk", "tests")
// simulate apps being created
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
Description: "this is a very cool app that does cool stuff",
Host: "http://localhost:3000",
})
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestAppTwo",
Description: "this is a very cool app that does cool stuff",
Host: "http://localhost:3001",
})
action.WriteRequestFiles(cfgPath, "TestApp", &action.RequestInfo{
Version: "1",
Name: "MyReq",
})
action.WriteRequestFiles(cfgPath, "TestAppTwo", &action.RequestInfo{
Version: "1",
Name: "AnotherReq",
})
// tests
app := app.New(cfgPath, http.Client{})
assert.Error(t, RunWithArgs(app, "delete", "req"), "delete req should fail with no args")
assert.Error(t, RunWithArgs(app, "delete", "req", "--confirm", "MyReq"), "delete req should fail with no default app")
assert.NoError(t, RunWithArgs(app, "delete", "req", "--confirm", "-a", "TestApp", "MyReq"), "delete req should NOT fail with existing req")
assert.Error(t, RunWithArgs(app, "delete", "req", "-a", "TestApp", "MyReq"), "delete req should fail with deleted req")
assert.Error(t, RunWithArgs(app, "delete", "req", "-a", "TestApp", "../malicious/path"), "delete req should fail invalid name")
// create default app
os.WriteFile(path.Join(cfgPath, "current_app"), []byte("TestAppTwo"), 0700)
assert.Error(t, RunWithArgs(app, "delete", "req", "AnotherReq", "somethingElse"), "delete req should fail with >1 arg")
assert.Error(t, RunWithArgs(app, "delete", "req", "NotRealApp"), "delete req should fail with unknown req")
assert.NoError(t, RunWithArgs(app, "delete", "req", "--confirm", "AnotherReq"), "delete req should NOT fail with default app and existing req")
assert.Error(t, RunWithArgs(app, "delete", "req", "AnotherReq"), "delete req should fail with deleted req")
os.RemoveAll("TestActionDeleteRequest")
// tests
}
func TestActionEditApp(t *testing.T) {
cfgPath := path.Join("TestActionEditApp", ".sp9rk", "tests")
// simulate apps being created
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
})
// tests
app := app.New(cfgPath, http.Client{})
assert.NoError(t,
RunWithArgs(app, "edit", "app", "-d", "new description", "-u", "http://123.456.789", "TestApp"),
"edit app should not fail with 1 arg and flags",
)
contents, err := os.ReadFile(action.AppInfoFilePath(cfgPath, "TestApp"))
assert.NoError(t, err, "new .appinfo file should exist")
appinfo := new(action.AppInfo)
err = yaml.Unmarshal(contents, appinfo)
assert.NoError(t, err, "new .appinfo file should be correctly formed")
assert.EqualValues(t, "new description", appinfo.Description, "description should be updated")
assert.EqualValues(t, "http://123.456.789", appinfo.Host, "host should be updated")
assert.Error(t, RunWithArgs(app, "edit", "app"), "edit app should fail with no args")
assert.NoError(t, RunWithArgs(app, "edit", "app", "TestApp"), "edit app should NOT fail with no flags")
os.RemoveAll("TestActionEditApp")
}
func TestActionEditRequest(t *testing.T) {
cfgPath := path.Join("TestActionEditRequest", ".sp9rk", "tests")
// simulate apps being created
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
})
action.WriteRequestFiles(cfgPath, "TestApp", &action.RequestInfo{
Name: "Req",
})
action.WriteRequestFiles(cfgPath, "TestApp", &action.RequestInfo{
Name: "ReqTwo",
Path: "/path",
Description: "hello",
Body: "request body",
Method: "POST",
Headers: []string{"Header: One"},
})
os.WriteFile(path.Join(cfgPath, "current_app"), []byte("TestApp"), 0700)
// tests
app := app.New(cfgPath, http.Client{})
assert.NoError(t,
RunWithArgs(app, "edit", "req",
"-a", "TestApp",
"-d", "new description",
"-X", "POST",
"-b", "new body",
"-H", "X-Header: ABC",
"-p", "/new-path",
"Req",
),
"edit app should not fail with 1 arg and flags",
)
contents, err := os.ReadFile(path.Join(action.AppPath(cfgPath, "TestApp"), "Req.yml"))
assert.NoError(t, err, "new request file should exist")
reqinfo := new(action.RequestInfo)
err = yaml.Unmarshal(contents, reqinfo)
assert.NoError(t, err, "new request file should be correctly formed")
assert.EqualValues(t, "new description", reqinfo.Description, "description should be updated")
assert.EqualValues(t, "POST", reqinfo.Method, "method should be updated")
assert.EqualValues(t, "new body", reqinfo.Body, "body should be updated")
assert.EqualValues(t, "X-Header: ABC", reqinfo.Headers[0], "header(s) should be updated")
assert.EqualValues(t, "/new-path", reqinfo.Path, "path should be updated")
assert.Error(t, RunWithArgs(app, "edit", "req"), "edit req should fail with no args")
assert.NoError(t, RunWithArgs(app, "edit", "req", "ReqTwo"), "edit req should NOT fail with no flags")
assert.NoError(t, RunWithArgs(app, "edit", "req", "-d", "new description", "ReqTwo"), "edit req should NOT fail when updating description")
contents, _ = os.ReadFile(path.Join(action.AppPath(cfgPath, "TestApp"), "ReqTwo.yml"))
reqinfo = new(action.RequestInfo)
yaml.Unmarshal(contents, reqinfo)
assert.EqualValues(t, "Header: One", reqinfo.Headers[0], "header(s) should NOT be overwritten when not updated")
assert.EqualValues(t, "/path", reqinfo.Path, "path should NOT be overwritten when not updated")
assert.EqualValues(t, "request body", reqinfo.Body, "header(s) should NOT be overwritten when not updated")
assert.EqualValues(t, "POST", reqinfo.Method, "method should NOT be overwritten when not updated")
os.RemoveAll("TestActionEditRequest")
}
func TestActionListApps(t *testing.T) {
cfgPath := path.Join("TestActionListApps", ".sp9rk", "tests")
// simulate apps being created
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
})
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "AnotherApp",
Description: "another one",
})
// tests
expected := "AnotherApp: another one\nTestApp\n"
app := app.New(cfgPath, http.Client{})
out, err := captureOutput(RunWithArgs, app, "list", "app")
assert.NoError(t, err, "list app should NOT generate an error")
assert.EqualValues(t, expected, out, "list app should be alphabetical")
os.RemoveAll("TestActionListApps")
}
func TestActionListRequests(t *testing.T) {
cfgPath := path.Join("TestActionListRequests", ".sp9rk", "tests")
// simulate apps being created
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestAppTwo",
})
action.WriteRequestFiles(cfgPath, "TestAppTwo", &action.RequestInfo{
Name: "Req",
})
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
})
action.WriteRequestFiles(cfgPath, "TestApp", &action.RequestInfo{
Name: "MyReq",
Description: "my request",
})
action.WriteRequestFiles(cfgPath, "TestApp", &action.RequestInfo{
Name: "AnotherReq",
})
os.WriteFile(path.Join(cfgPath, "current_app"), []byte("TestApp"), 0700)
// tests
expected := "AnotherReq\nMyReq: my request\n"
app := app.New(cfgPath, http.Client{})
out, err := captureOutput(RunWithArgs, app, "list", "req")
assert.NoError(t, err, "list req should NOT generate an error")
assert.EqualValues(t, expected, out, "list req should succeed with default app")
expected = "Req\n"
out, err = captureOutput(RunWithArgs, app, "list", "req", "-a", "TestAppTwo")
assert.NoError(t, err, "list req should NOT generate an error")
assert.EqualValues(t, expected, out, "list req should succeed with specific app")
os.RemoveAll("TestActionListRequests")
}
func TestActionListAll(t *testing.T) {
cfgPath := path.Join("TestActionListAll", ".sp9rk", "tests")
// simulate apps being created
app := app.New(cfgPath, http.Client{})
expected := "No applications or requests\n"
out, err := captureOutput(RunWithArgs, app, "list")
assert.NoError(t, err, "list all with nothing loaded should NOT generate an error")
assert.EqualValues(t, expected, out, "list should generate expected output")
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestAppTwo",
})
expected = "TestAppTwo\n"
out, err = captureOutput(RunWithArgs, app, "list")
assert.NoError(t, err, "list req should NOT generate an error")
assert.EqualValues(t, expected, out, "list should generate expected output")
action.WriteRequestFiles(cfgPath, "TestAppTwo", &action.RequestInfo{
Name: "Req",
})
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
})
action.WriteRequestFiles(cfgPath, "TestApp", &action.RequestInfo{
Name: "MyReq",
Description: "my request",
})
action.WriteRequestFiles(cfgPath, "TestApp", &action.RequestInfo{
Name: "AnotherReq",
})
expected = `TestApp:
- AnotherReq
- MyReq: my request
TestAppTwo:
- Req
`
out, err = captureOutput(RunWithArgs, app, "list")
assert.NoError(t, err, "list req should NOT generate an error")
assert.EqualValues(t, expected, out, "list should generate expected output")
os.RemoveAll("TestActionListAll")
}
func TestActionAppInfo(t *testing.T) {
cfgPath := path.Join("TestActionAppInfo", ".sp9rk", "tests")
// simulate apps being created
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
Description: "test application",
Host: "http://123.456.789",
})
// tests
expected := `TestApp:
Description: test application
Host: http://123.456.789
`
app := app.New(cfgPath, http.Client{})
out, err := captureOutput(RunWithArgs, app, "info", "app", "TestApp")
assert.NoError(t, err, "list req should NOT generate an error")
assert.EqualValues(t, expected, out, "list should generate expected output")
assert.Error(t, RunWithArgs(app, "info", "app"), "info app should generate an error with no args")
os.RemoveAll("TestActionAppInfo")
}
func TestActionRequestInfo(t *testing.T) {
cfgPath := path.Join("TestActionRequestInfo", ".sp9rk", "tests")
// simulate apps being created
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
})
action.WriteRequestFiles(cfgPath, "TestApp", &action.RequestInfo{
Name: "MyReq",
Method: "GET",
Description: "my request",
Path: "/path",
Body: "request body",
Headers: []string{
"X-API-Key: ABC123",
},
})
// tests
expected := `MyReq:
description: my request
method: GET
path: /path
headers:
- 'X-API-Key: ABC123'
body: request body
`
app := app.New(cfgPath, http.Client{})
out, err := captureOutput(RunWithArgs, app, "info", "req", "-a", "TestApp", "MyReq")
assert.NoError(t, err, "list req should NOT generate an error")
assert.EqualValues(t, expected, out, "list should generate expected output")
os.WriteFile(path.Join(cfgPath, "current_app"), []byte("TestApp"), 0700)
out, err = captureOutput(RunWithArgs, app, "info", "req", "MyReq")
assert.NoError(t, err, "list req should NOT generate an error")
assert.EqualValues(t, expected, out, "list should generate expected output with default app")
assert.Error(t, RunWithArgs(app, "info", "req"), "info req should generate an error with no args")
os.RemoveAll("TestActionRequestInfo")
}
type ClientMock struct{}
func (c *ClientMock) Do(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: "OK",
StatusCode: 200,
Body: io.NopCloser(strings.NewReader("Hello, world!")),
}, nil
}
func TestActionCall(t *testing.T) {
cfgPath := path.Join("TestActionCall", ".sp9rk", "tests")
// simulate apps being created
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.EqualValues(t, "/path", r.URL.Path, "path is incorrect")
assert.EqualValues(t, r.Header.Get("X-API-Key"), "ABC123", "headers are incorrect")
assert.EqualValues(t, r.Method, "GET", "method is incorrect")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`Hello, World!`))
}))
defer server.Close()
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
Host: server.URL,
})
action.WriteRequestFiles(cfgPath, "TestApp", &action.RequestInfo{
Name: "MyReq",
Method: "GET",
Description: "my request",
Path: "/path",
Body: "request body",
Headers: []string{
"X-API-Key: ABC123",
},
})
// test basic functionality
app := app.New(cfgPath, http.Client{})
assert.Error(t, RunWithArgs(app, "call"), "call should fail with no argument")
assert.Error(t, RunWithArgs(app, "call", "MyReq"), "call should fail with no default app")
assert.Error(t, RunWithArgs(app, "call", "../bad/path"), "call should fail invalid request name")
assert.Error(t, RunWithArgs(app, "call", "-a", "../bad/app/path", "MyReq"), "call should fail with invalid app name")
assert.Error(t, RunWithArgs(app, "call", "-a", "TestApp", "FakeReq"), "call should fail with unknown app")
assert.Error(t, RunWithArgs(app, "call", "MyReq"), "call should fail with no default app")
output, err := captureOutput(RunWithArgs, app, "call", "-a", "TestApp", "MyReq")
assert.EqualValues(t, "Hello, World!\n", output, "call output should be response body")
assert.NoError(t, err, "call should succeed with explicit app")
os.WriteFile(path.Join(cfgPath, "current_app"), []byte("TestApp"), 0700)
output, err = captureOutput(RunWithArgs, app, "call", "MyReq")
assert.NoError(t, err, "call should succeed with default app")
assert.EqualValues(t, "Hello, World!\n", output, "call output should be response body")
// test extended functionality (flags)
output, err = captureOutput(RunWithArgs, app, "call", "--verbose", "MyReq")
assert.NoError(t, err, "call should succeed with verbose flag")
respData := new(action.VerboseCallResponse)
err = yaml.Unmarshal([]byte(output), respData)
assert.NoError(t, err, "output should be valid yaml")
assert.Equal(t, "GET "+server.URL+"/path", respData.Request, "request is incorrect")
assert.NotEmpty(t, respData.Latency, "latency must not be empty")
assert.Equal(t, "request body", respData.RequestBody, "request body is incorrect")
assert.Equal(t, "200 OK", respData.Status, "response status is incorrect")
assert.NotNil(t, respData.Headers, "request headers must not be nil")
assert.Equal(t, "ABC123", respData.Headers["X-API-Key"], "request headers are incorrect")
assert.Equal(t, "Hello, World!", respData.ResponseBody, "response body is incorrect")
os.RemoveAll("TestActionCall")
}
func TestActionCallFail(t *testing.T) {
cfgPath := path.Join("TestActionCallFail", ".sp9rk", "tests")
// make a new server that will make every request return >=400 status
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(400)
w.Write([]byte(`Hello, World!`))
}))
defer server.Close()
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
Host: server.URL,
})
action.WriteRequestFiles(cfgPath, "TestApp", &action.RequestInfo{
Name: "MyReq",
Method: "GET",
Description: "my request",
Path: "/path",
Body: "request body",
Headers: []string{
"X-API-Key: ABC123",
},
})
os.WriteFile(path.Join(cfgPath, "current_app"), []byte("TestApp"), 0700)
app := app.New(cfgPath, http.Client{})
output, err := captureOutput(RunWithArgs, app, "call", "--fail", "MyReq")
assert.Error(t, err, "call should fail with fail flag")
assert.Empty(t, output, "failed request should be silent with fail flag")
os.RemoveAll("TestActionCallFail")
}
// TODO test redirects
func TestActionCallRedirects(t *testing.T) {
cfgPath := path.Join("TestActionCallLocation", ".sp9rk", "tests")
// make a new server that will make every request return >=400 status
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/hello" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`Hello, World!`))
return
}
w.WriteHeader(404)
}))
defer server.Close()
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/redirect-me" {
w.Header().Add("Location", server.URL+"/hello")
w.WriteHeader(http.StatusMovedPermanently)
w.Write([]byte(`Don't follow me!`))
return
}
w.WriteHeader(404)
}))
defer server2.Close()
action.WriteAppFiles(cfgPath, &action.AppInfo{
Name: "TestApp",
Host: server2.URL,
})
action.WriteRequestFiles(cfgPath, "TestApp", &action.RequestInfo{
Name: "MyReq",
Method: "GET",
Description: "my request",
Path: "/redirect-me",
})
os.WriteFile(path.Join(cfgPath, "current_app"), []byte("TestApp"), 0700)
app := app.New(cfgPath, http.Client{})
output, err := captureOutput(RunWithArgs, app, "call", "MyReq")
assert.NoError(t, err, "call should not fail when following valid redirect")
assert.Equal(t, "Hello, World!\n", output, "output should be equal to response body")
output, err = captureOutput(RunWithArgs, app, "call", "--no-redirect", "MyReq")
assert.NoError(t, err, "call should not fail when not following redirect")
assert.Equal(t, "Don't follow me!\n", output, "output should be equal to response body")
os.RemoveAll("TestActionCallLocation")
}
// helper functions for testing
func RunWithArgs(app *cli.App, args ...string) error {
a := os.Args[0:1]
a = append(a, args...)
return app.Run(a)
}
func captureOutput(f func(*cli.App, ...string) error, app *cli.App, args ...string) (string, error) {
orig := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := f(app, args...)
os.Stdout = orig
w.Close()
out, _ := io.ReadAll(r)
return string(out), err
}

52
action/files.go Normal file
View file

@ -0,0 +1,52 @@
package action
import (
"errors"
"os"
"path"
"gopkg.in/yaml.v3"
)
type RequestInfo struct {
Version string `yaml:"version"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Method string `yaml:"method"`
Path string `yaml:"path"`
Headers []string `yaml:"headers"`
Body string `yaml:"body"`
}
func WriteRequestFiles(cfgPath, app string, req *RequestInfo) error {
data, err := yaml.Marshal(req)
if err != nil {
return errors.New("failed to marshal data")
}
path := path.Join(AppPath(cfgPath, app), req.Name+".yml")
return os.WriteFile(path, data, 0700)
}
type AppInfo struct {
Version string `yaml:"version"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Host string `yaml:"host"`
}
func WriteAppFiles(cfgPath string, app *AppInfo) error {
data, err := yaml.Marshal(app)
if err != nil {
return errors.New("failed to marshal data")
}
err = os.MkdirAll(AppPath(cfgPath, app.Name), 0700)
if err != nil {
return err
}
err = os.WriteFile(AppInfoFilePath(cfgPath, app.Name), data, 0700)
if err != nil {
os.Remove(AppPath(cfgPath, app.Name))
return err
}
return nil
}

78
action/helpers.go Normal file
View file

@ -0,0 +1,78 @@
package action
import (
"bufio"
"errors"
"fmt"
"log"
"os"
"path"
"regexp"
"strings"
"github.com/urfave/cli/v2"
)
// If the --app flag is set, returns that app. Otherwise, returns the default app.
// If no default exists, returns an error.
func getApp(cfgPath string, ctx *cli.Context) (string, error) {
var app string
if ctx.String("app") == "" {
app = currentApp(cfgPath)
if app == "" {
return "", errors.New("application does not exist")
}
} else {
app = ctx.String("app")
}
return app, nil
}
// "" if no current app is set
func currentApp(cfgPath string) string {
if _, err := os.Stat(path.Join(cfgPath, "current_app")); err != nil {
return ""
}
app, err := os.ReadFile(path.Join(cfgPath, "current_app"))
if err != nil {
return ""
}
return string(app)
}
// TRUE if string is alphanumeric with - or _
func valid(name string) bool {
return regexp.MustCompile(`^[a-zA-Z0-9_-]*$`).MatchString(name)
}
// TRUE if app folder exists
func appExists(cfgPath, app string) bool {
_, err := os.Stat(AppPath(cfgPath, app))
return err == nil
}
func ConfirmPrompt() bool {
fmt.Print("Are you sure? [y/N]: ")
r := bufio.NewReader(os.Stdin)
res, err := r.ReadString('\n')
if err != nil {
log.Fatal(err)
}
// if empty, default to N
if len(res) < 2 {
return false
}
return strings.ToLower(strings.TrimSpace(res))[0] == 'y'
}
func AppPath(cfgPath, app string) string {
return path.Join(cfgPath, "apps", app)
}
func AppInfoFilePath(cfgPath, app string) string {
return path.Join(AppPath(cfgPath, app), ".appinfo")
}
func ReqPath(cfgPath, app, req string) string {
return path.Join(cfgPath, "apps", app, req+".yml")
}