- added unit tests
    - put all subcmds into one file
    - use io.Writer for output, better for testing
    - added upload form support
    - added api docs
    - generalized db engine
    - added mail notify support for forms
    - enhanced server/SetupAuthStore() to also look up form ids
    - added form template (put into .go file by Makefile
    - renamed project
This commit is contained in:
2023-03-21 19:41:24 +01:00
parent b8816f910a
commit 05fa5cd41b
41 changed files with 1973 additions and 545 deletions

View File

@@ -25,8 +25,9 @@ import (
"github.com/imroc/req/v3"
"github.com/jarcoal/httpmock"
"github.com/schollz/progressbar/v3"
"github.com/tlinden/cenophane/common"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/ephemerup/common"
"github.com/tlinden/ephemerup/upctl/cfg"
"io"
"mime"
"os"
"path/filepath"
@@ -49,8 +50,11 @@ type ListParams struct {
Apicontext string `json:"apicontext"`
}
const Maxwidth = 10
const Maxwidth = 12
/*
Create a new request object for outgoing queries
*/
func Setup(c *cfg.Config, path string) *Request {
client := req.C()
if c.Debug {
@@ -86,9 +90,12 @@ func Setup(c *cfg.Config, path string) *Request {
}
return &Request{Url: c.Endpoint + path, R: R}
}
/*
Iterate over args, considering the elements are filenames, and add
them to the request.
*/
func GatherFiles(rq *Request, args []string) error {
for _, file := range args {
info, err := os.Stat(file)
@@ -120,9 +127,48 @@ func GatherFiles(rq *Request, args []string) error {
return nil
}
func UploadFiles(c *cfg.Config, args []string) error {
/*
Check HTTP Response Code and validate JSON status output, if
any. Turns'em into a regular error
*/
func HandleResponse(c *cfg.Config, resp *req.Response) error {
// we expect a json response, extract the error, if any
r := Response{}
if c.Debug {
trace := resp.Request.TraceInfo()
fmt.Println(trace.Blame())
fmt.Println("----------")
fmt.Println(trace)
}
if err := json.Unmarshal([]byte(resp.String()), &r); err != nil {
// text output!
r.Message = resp.String()
}
if !resp.IsSuccessState() {
return fmt.Errorf("bad response: %s (%s)", resp.Status, r.Message)
}
if !r.Success {
if len(r.Message) == 0 {
if resp.Err != nil {
return resp.Err
} else {
return errors.New("Unknown error")
}
} else {
return errors.New(r.Message)
}
}
return nil
}
func UploadFiles(w io.Writer, c *cfg.Config, args []string) error {
// setup url, req.Request, timeout handling etc
rq := Setup(c, "/file/")
rq := Setup(c, "/uploads")
// collect files to upload from @argv
if err := GatherFiles(rq, args); err != nil {
@@ -150,47 +196,15 @@ func UploadFiles(c *cfg.Config, args []string) error {
return err
}
return RespondExtended(resp)
if err := HandleResponse(c, resp); err != nil {
return err
}
return RespondExtended(w, resp)
}
func HandleResponse(c *cfg.Config, resp *req.Response) error {
// we expect a json response, extract the error, if any
r := Response{}
if err := json.Unmarshal([]byte(resp.String()), &r); err != nil {
// text output!
r.Message = resp.String()
}
if c.Debug {
trace := resp.Request.TraceInfo()
fmt.Println(trace.Blame())
fmt.Println("----------")
fmt.Println(trace)
}
if !r.Success {
if len(r.Message) == 0 {
if resp.Err != nil {
return resp.Err
} else {
return errors.New("Unknown error")
}
} else {
return errors.New(r.Message)
}
}
// all right
if r.Message != "" {
fmt.Println(r.Message)
}
return nil
}
func List(c *cfg.Config, args []string) error {
rq := Setup(c, "/list/")
func List(w io.Writer, c *cfg.Config, args []string) error {
rq := Setup(c, "/uploads")
params := &ListParams{Apicontext: c.Apicontext}
resp, err := rq.R.
@@ -201,12 +215,16 @@ func List(c *cfg.Config, args []string) error {
return err
}
return RespondTable(resp)
if err := HandleResponse(c, resp); err != nil {
return err
}
return UploadsRespondTable(w, resp)
}
func Delete(c *cfg.Config, args []string) error {
func Delete(w io.Writer, c *cfg.Config, args []string) error {
for _, id := range args {
rq := Setup(c, "/file/"+id+"/")
rq := Setup(c, "/uploads/"+id+"/")
resp, err := rq.R.Delete(rq.Url)
@@ -218,47 +236,67 @@ func Delete(c *cfg.Config, args []string) error {
return err
}
fmt.Printf("Upload %s successfully deleted.\n", id)
fmt.Fprintf(w, "Upload %s successfully deleted.\n", id)
}
return nil
}
func Describe(c *cfg.Config, args []string) error {
func Describe(w io.Writer, c *cfg.Config, args []string) error {
if len(args) == 0 {
return errors.New("No id provided!")
}
id := args[0] // we describe only 1 object
rq := Setup(c, "/upload/"+id+"/")
rq := Setup(c, "/uploads/"+id)
resp, err := rq.R.Get(rq.Url)
if err != nil {
return err
}
return RespondExtended(resp)
}
func Download(c *cfg.Config, args []string) error {
id := args[0]
// progres bar
bar := progressbar.Default(100)
callback := func(info req.DownloadInfo) {
if info.Response.Response != nil {
bar.Add(1)
}
if err := HandleResponse(c, resp); err != nil {
return err
}
return RespondExtended(w, resp)
}
func Download(w io.Writer, c *cfg.Config, args []string) error {
if len(args) == 0 {
return errors.New("No id provided!")
}
id := args[0]
rq := Setup(c, "/uploads/"+id+"/file")
if !c.Silent {
// progres bar
bar := progressbar.Default(100)
callback := func(info req.DownloadInfo) {
if info.Response.Response != nil {
bar.Add(1)
}
}
rq.R.SetDownloadCallback(callback)
}
rq := Setup(c, "/file/"+id+"/")
resp, err := rq.R.
SetOutputFile(id).
SetDownloadCallback(callback).
Get(rq.Url)
if err != nil {
return err
}
if !resp.IsSuccessState() {
return fmt.Errorf("bad response: %s", resp.Status)
}
_, params, err := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
if err != nil {
os.Remove(id)
@@ -278,7 +316,34 @@ func Download(c *cfg.Config, args []string) error {
return fmt.Errorf("\nUnable to rename file: " + err.Error())
}
fmt.Printf("%s successfully downloaded to file %s.", id, cleanfilename)
fmt.Fprintf(w, "%s successfully downloaded to file %s.", id, cleanfilename)
return nil
}
/**** Forms stuff ****/
func CreateForm(w io.Writer, c *cfg.Config) error {
// setup url, req.Request, timeout handling etc
rq := Setup(c, "/forms")
// actual post w/ settings
resp, err := rq.R.
SetFormData(map[string]string{
"expire": c.Expire,
"description": c.Description,
"notify": c.Notify,
}).
Post(rq.Url)
if err != nil {
return err
}
if err := HandleResponse(c, resp); err != nil {
return err
}
return RespondExtended(w, resp)
return nil
}

View File

@@ -19,37 +19,85 @@ package lib
import (
//"github.com/alecthomas/repr"
"bytes"
"fmt"
"github.com/jarcoal/httpmock"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/ephemerup/upctl/cfg"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
const endpoint string = "http://localhost:8080/api/v1"
const endpoint string = "http://localhost:8080/v1"
type Unit struct {
name string
apikey string // set to something else than "token" to fail auth
wantfail bool // true: expect to fail
files []string // path relative to ./t/
sendcode int // for httpmock
sendjson string // struct to respond with
route string // dito
method string // method to use
expect string // regex used to parse the output
sendcode int // for httpmock
sendjson string // struct to respond with
sendfile string // bare file content to be sent
route string // dito
method string // method to use
}
// simulate our cenophane server
// simulate our ephemerup server
func Intercept(tt Unit) {
httpmock.RegisterResponder(tt.method, endpoint+tt.route,
func(request *http.Request) (*http.Response, error) {
respbody := fmt.Sprintf(tt.sendjson)
resp := httpmock.NewStringResponse(tt.sendcode, respbody)
resp.Header.Set("Content-Type", "application/json; charset=utf-8")
var resp *http.Response
if tt.sendfile != "" {
// simulate a file download
content, err := ioutil.ReadFile(tt.sendfile)
if err != nil {
panic(err) // should not happen
}
stat, err := os.Stat(tt.sendfile)
if err != nil {
panic(err) // should not happen as well
}
resp = httpmock.NewStringResponse(tt.sendcode, string(content))
resp.Header.Set("Content-Type", "text/markdown; charset=utf-8")
resp.Header.Set("Content-Length", strconv.Itoa(int(stat.Size())))
resp.Header.Set("Content-Disposition", "attachment; filename='t1'")
} else {
// simulate JSON response
resp = httpmock.NewStringResponse(tt.sendcode, tt.sendjson)
resp.Header.Set("Content-Type", "application/json; charset=utf-8")
}
return resp, nil
})
}
// execute the actual test
func Check(t *testing.T, tt Unit, w *bytes.Buffer, err error) {
testname := fmt.Sprintf("%s-%t", tt.name, tt.wantfail)
if err != nil && !tt.wantfail {
t.Errorf("%s failed! wantfail: %t, error: %s", testname, tt.wantfail, err.Error())
}
if tt.expect != "" {
got := strings.TrimSpace(w.String())
r := regexp.MustCompile(tt.expect)
if !r.MatchString(got) {
t.Errorf("%s failed! error: output does not match!\nexpect: %s\ngot:\n%s", testname, tt.expect, got)
}
}
}
func TestUploadFiles(t *testing.T) {
conf := &cfg.Config{
Mock: true,
@@ -63,42 +111,67 @@ func TestUploadFiles(t *testing.T) {
name: "upload-file",
apikey: "token",
wantfail: false,
route: "/file/",
route: "/uploads",
sendcode: 200,
sendjson: `{"success": true}`,
files: []string{"../t/t1"}, // pwd is lib/ !
method: "POST",
},
{
name: "upload-nonexistent-file",
name: "upload-dir",
apikey: "token",
wantfail: false,
route: "/uploads",
sendcode: 200,
sendjson: `{"success": true}`,
files: []string{"../t"}, // pwd is lib/ !
method: "POST",
},
{
name: "upload-catch-nonexistent-file",
apikey: "token",
wantfail: true,
route: "/file/",
route: "/uploads",
sendcode: 200,
sendjson: `{"success": false}`,
files: []string{"../t/none"},
method: "POST",
},
{
name: "upload-unauth",
name: "upload-catch-no-access",
apikey: "token",
wantfail: true,
route: "/file/",
route: "/uploads",
sendcode: 403,
sendjson: `{"success": false}`,
files: []string{"../t/t1"},
method: "POST",
},
{
name: "upload-check-output",
apikey: "token",
wantfail: false,
route: "/uploads",
sendcode: 200,
sendjson: `{"uploads":[
{
"id":"cc2c965a","expire":"asap","file":"t1","members":["t1"],
"uploaded":1679396814.890502,"context":"foo","url":""
}
],
"success":true,
"message":"Download url: http://localhost:8080/download/cc2c965a/t1",
"code":200}`,
files: []string{"../t/t1"}, // pwd is lib/ !
method: "POST",
expect: "Expire: On first access",
},
}
for _, tt := range tests {
testname := fmt.Sprintf("UploadFiles-%s-%t", tt.name, tt.wantfail)
Intercept(tt)
err := UploadFiles(conf, tt.files)
if err != nil && !tt.wantfail {
t.Errorf("%s failed! wantfail: %t, error: %s", testname, tt.wantfail, err.Error())
}
for _, unit := range tests {
var w bytes.Buffer
Intercept(unit)
Check(t, unit, &w, UploadFiles(&w, conf, unit.files))
}
}
@@ -110,28 +183,227 @@ func TestList(t *testing.T) {
Silent: true,
}
listing := `{"uploads":[{"id":"c8dh","expire":"asap","file":"t1","members":["t1"],"uploaded":1679318969.6434112,"context":"foo","url":""}],"success":true,"message":"","code":200}`
listing := `{"uploads":[
{
"id":"cc2c965a","expire":"asap","file":"t1","members":["t1"],
"uploaded":1679396814.890502,"context":"foo","url":""
}
],
"success":true,
"message":"",
"code":200}`
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "list",
apikey: "token",
wantfail: false,
route: "/list/",
route: "/uploads",
sendcode: 200,
sendjson: listing,
files: []string{},
method: "GET",
expect: `cc2c965a\s*asap\s*foo\s*2023-03-21 12:06:54`, // expect tabular output
},
{
name: "list-catch-empty-json",
apikey: "token",
wantfail: true,
route: "/uploads",
sendcode: 404,
sendjson: "",
files: []string{},
method: "GET",
},
{
name: "list-catch-no-access",
apikey: "token",
wantfail: true,
route: "/uploads",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{},
method: "GET",
},
}
for _, tt := range tests {
testname := fmt.Sprintf("List-%s-%t", tt.name, tt.wantfail)
Intercept(tt)
err := List(conf, []string{})
for _, unit := range tests {
var w bytes.Buffer
Intercept(unit)
Check(t, unit, &w, List(&w, conf, []string{}))
}
}
if err != nil && !tt.wantfail {
t.Errorf("%s failed! wantfail: %t, error: %s", testname, tt.wantfail, err.Error())
}
func TestDescribe(t *testing.T) {
conf := &cfg.Config{
Mock: true,
Apikey: "token",
Endpoint: endpoint,
Silent: true,
}
listing := `{"uploads":[
{
"id":"cc2c965a","expire":"asap","file":"t1","members":["t1"],
"uploaded":1679396814.890502,"context":"foo","url":""
}
],
"success":true,
"message":"",
"code":200}`
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "describe",
apikey: "token",
wantfail: false,
route: "/uploads/",
sendcode: 200,
sendjson: listing,
files: []string{"cc2c965a"},
method: "GET",
expect: `Created: 2023-03-21 12:06:54.890501888`,
},
{
name: "describe-catch-empty-json",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 200,
sendjson: "",
files: []string{"cc2c965a"},
method: "GET",
},
{
name: "describe-catch-no-access",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{"cc2c965a"},
method: "GET",
},
}
for _, unit := range tests {
var w bytes.Buffer
unit.route += unit.files[0]
Intercept(unit)
Check(t, unit, &w, Describe(&w, conf, unit.files))
}
}
func TestDelete(t *testing.T) {
conf := &cfg.Config{
Mock: true,
Apikey: "token",
Endpoint: endpoint,
Silent: true,
}
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "delete",
apikey: "token",
wantfail: false,
route: "/uploads/",
sendcode: 200,
sendjson: `{"success":true,"message":"","code":200}`,
files: []string{"cc2c965a"},
method: "DELETE",
expect: `Upload cc2c965a successfully deleted`,
},
{
name: "delete-catch-empty-json",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 200,
sendjson: "",
files: []string{"cc2c965a"},
method: "DELETE",
},
{
name: "delete-catch-no-access",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{"cc2c965a"},
method: "DELETE",
},
}
for _, unit := range tests {
var w bytes.Buffer
unit.route += unit.files[0] + "/"
Intercept(unit)
Check(t, unit, &w, Delete(&w, conf, unit.files))
}
}
func TestDownload(t *testing.T) {
conf := &cfg.Config{
Mock: true,
Apikey: "token",
Endpoint: endpoint,
Silent: true,
}
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "download",
apikey: "token",
wantfail: false,
route: "/uploads/",
sendcode: 200,
sendfile: "../t/t1",
files: []string{"cc2c965a"},
method: "GET",
expect: `cc2c965a successfully downloaded to file t1`,
},
{
name: "download-catch-empty-response",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 200,
files: []string{"cc2c965a"},
method: "GET",
},
{
name: "download-catch-no-access",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{"cc2c965a"},
method: "GET",
},
}
for _, unit := range tests {
var w bytes.Buffer
unit.route += unit.files[0] + "/file"
Intercept(unit)
Check(t, unit, &w, Download(&w, conf, unit.files))
if unit.sendfile != "" {
file := filepath.Base(unit.sendfile)
if _, err := os.Stat(file); err == nil {
os.Remove(file)
}
}
}
}

View File

@@ -23,8 +23,9 @@ import (
"fmt"
"github.com/imroc/req/v3"
"github.com/olekukonko/tablewriter"
"github.com/tlinden/cenophane/common"
"os"
"github.com/tlinden/ephemerup/common"
"io"
"strings"
"time"
)
@@ -34,15 +35,17 @@ func prepareExpire(expire string, start common.Timestamp) string {
case "asap":
return "On first access"
default:
return time.Unix(start.Unix()+int64(common.Duration2int(expire)), 0).Format("2006-01-02 15:04:05")
return time.Unix(start.Unix()+int64(common.Duration2int(expire)), 0).
Format("2006-01-02 15:04:05")
}
return ""
}
// generic table writer
func WriteTable(headers []string, data [][]string) {
table := tablewriter.NewWriter(os.Stdout)
func WriteTable(w io.Writer, headers []string, data [][]string) {
tableString := &strings.Builder{}
table := tablewriter.NewWriter(tableString)
table.SetHeader(headers)
table.AppendBulk(data)
@@ -60,76 +63,95 @@ func WriteTable(headers []string, data [][]string) {
table.SetNoWhiteSpace(true)
table.Render()
fmt.Fprintln(w, tableString.String())
}
// output like psql \x
func WriteExtended(uploads *common.Uploads) {
/* Print output like psql \x
Prints all Uploads and Forms which exist in common.Response,
however, we expect only one kind of them to be actually filled, so
the function can be used for forms and uploads.
*/
func WriteExtended(w io.Writer, response *common.Response) {
format := fmt.Sprintf("%%%ds: %%s\n", Maxwidth)
// we shall only have 1 element, however, if we ever support more, here we go
for _, entry := range uploads.Entries {
expire := prepareExpire(entry.Expire, entry.Uploaded)
fmt.Printf(format, "Id", entry.Id)
fmt.Printf(format, "Expire", expire)
fmt.Printf(format, "Context", entry.Context)
fmt.Printf(format, "Uploaded", entry.Uploaded)
fmt.Printf(format, "Filename", entry.File)
fmt.Printf(format, "Url", entry.Url)
fmt.Println()
for _, entry := range response.Uploads {
expire := prepareExpire(entry.Expire, entry.Created)
fmt.Fprintf(w, format, "Id", entry.Id)
fmt.Fprintf(w, format, "Expire", expire)
fmt.Fprintf(w, format, "Context", entry.Context)
fmt.Fprintf(w, format, "Created", entry.Created)
fmt.Fprintf(w, format, "Filename", entry.File)
fmt.Fprintf(w, format, "Url", entry.Url)
fmt.Fprintln(w)
}
for _, entry := range response.Forms {
expire := prepareExpire(entry.Expire, entry.Created)
fmt.Fprintf(w, format, "Id", entry.Id)
fmt.Fprintf(w, format, "Expire", expire)
fmt.Fprintf(w, format, "Context", entry.Context)
fmt.Fprintf(w, format, "Created", entry.Created)
fmt.Fprintf(w, format, "Description", entry.Description)
fmt.Fprintf(w, format, "Notify", entry.Notify)
fmt.Fprintf(w, format, "Url", entry.Url)
fmt.Fprintln(w)
}
}
// extract an common.Uploads{} struct from json response
func GetUploadsFromResponse(resp *req.Response) (*common.Uploads, error) {
uploads := common.Uploads{}
func GetResponse(resp *req.Response) (*common.Response, error) {
response := common.Response{}
if err := json.Unmarshal([]byte(resp.String()), &uploads); err != nil {
if err := json.Unmarshal([]byte(resp.String()), &response); err != nil {
return nil, errors.New("Could not unmarshall JSON response: " + err.Error())
}
if !uploads.Success {
return nil, errors.New(uploads.Message)
if !response.Success {
return nil, errors.New(response.Message)
}
return &uploads, nil
return &response, nil
}
// turn the Uploads{} struct into a table and print it
func RespondTable(resp *req.Response) error {
uploads, err := GetUploadsFromResponse(resp)
func UploadsRespondTable(w io.Writer, resp *req.Response) error {
response, err := GetResponse(resp)
if err != nil {
return err
}
if uploads.Message != "" {
fmt.Println(uploads.Message)
if response.Message != "" {
fmt.Fprintln(w, response.Message)
}
// tablewriter
data := [][]string{}
for _, entry := range uploads.Entries {
for _, entry := range response.Uploads {
data = append(data, []string{
entry.Id, entry.Expire, entry.Context, entry.Uploaded.Format("2006-01-02 15:04:05"),
entry.Id, entry.Expire, entry.Context, entry.Created.Format("2006-01-02 15:04:05"),
})
}
WriteTable([]string{"ID", "EXPIRE", "CONTEXT", "UPLOADED"}, data)
WriteTable(w, []string{"ID", "EXPIRE", "CONTEXT", "CREATED"}, data)
return nil
}
// turn the Uploads{} struct into xtnd output and print it
func RespondExtended(resp *req.Response) error {
uploads, err := GetUploadsFromResponse(resp)
func RespondExtended(w io.Writer, resp *req.Response) error {
response, err := GetResponse(resp)
if err != nil {
return err
}
if uploads.Message != "" {
fmt.Println(uploads.Message)
if response.Message != "" {
fmt.Fprintln(w, response.Message)
}
WriteExtended(uploads)
WriteExtended(w, response)
return nil
}