diff --git a/compose/automation/attachment_handler.gen.go b/compose/automation/attachment_handler.gen.go index 3d36ddc934..cd490813f9 100644 --- a/compose/automation/attachment_handler.gen.go +++ b/compose/automation/attachment_handler.gen.go @@ -122,6 +122,9 @@ type ( hasResource bool Resource *types.Record + hasFieldName bool + FieldName string + hasContent bool Content interface{} contentString string @@ -162,6 +165,10 @@ func (h attachmentHandler) Create() *atypes.Function { Name: "resource", Types: []string{"ComposeRecord"}, Required: true, }, + { + Name: "fieldName", + Types: []string{"String"}, + }, { Name: "content", Types: []string{"String", "Reader", "Bytes"}, Required: true, @@ -179,9 +186,10 @@ func (h attachmentHandler) Create() *atypes.Function { Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { var ( args = &attachmentCreateArgs{ - hasName: in.Has("name"), - hasResource: in.Has("resource"), - hasContent: in.Has("content"), + hasName: in.Has("name"), + hasResource: in.Has("resource"), + hasFieldName: in.Has("fieldName"), + hasContent: in.Has("content"), } ) diff --git a/compose/automation/attachment_handler.go b/compose/automation/attachment_handler.go index b8635a1762..c3c7b6c2fd 100644 --- a/compose/automation/attachment_handler.go +++ b/compose/automation/attachment_handler.go @@ -13,7 +13,7 @@ import ( type ( attachmentService interface { FindByID(ctx context.Context, namespaceID, attachmentID uint64) (*types.Attachment, error) - CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64) (att *types.Attachment, err error) + CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64, fieldName string) (att *types.Attachment, err error) DeleteByID(ctx context.Context, namespaceID uint64, attachmentID uint64) error OpenOriginal(att *types.Attachment) (io.ReadSeeker, error) OpenPreview(att *types.Attachment) (io.ReadSeeker, error) @@ -113,6 +113,7 @@ func (h attachmentHandler) create(ctx context.Context, args *attachmentCreateArg fh, args.Resource.ModuleID, args.Resource.ID, + args.FieldName, ) default: return nil, fmt.Errorf("unknown resource") diff --git a/compose/automation/attachment_handler.yaml b/compose/automation/attachment_handler.yaml index 50184ce009..d2cb000865 100644 --- a/compose/automation/attachment_handler.yaml +++ b/compose/automation/attachment_handler.yaml @@ -39,6 +39,10 @@ functions: types: - { wf: ComposeRecord } + fieldName: + types: + - { wf: String } + content: required: true types: diff --git a/compose/rest/record.go b/compose/rest/record.go index edd91f967d..cf6d238de9 100644 --- a/compose/rest/record.go +++ b/compose/rest/record.go @@ -274,6 +274,7 @@ func (ctrl *Record) Upload(ctx context.Context, r *request.RecordUpload) (interf file, r.ModuleID, r.RecordID, + r.FieldName, ) return makeAttachmentPayload(ctx, a, err) diff --git a/compose/service/attachment.go b/compose/service/attachment.go index fe76de400e..20fa646637 100644 --- a/compose/service/attachment.go +++ b/compose/service/attachment.go @@ -8,21 +8,30 @@ import ( "io" "net/http" "path" + "regexp" "strings" "github.com/cortezaproject/corteza-server/compose/types" "github.com/cortezaproject/corteza-server/pkg/actionlog" "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/errors" "github.com/cortezaproject/corteza-server/pkg/objstore" "github.com/cortezaproject/corteza-server/store" + systemService "github.com/cortezaproject/corteza-server/system/service" "github.com/disintegration/imaging" "github.com/edwvee/exiffix" - "github.com/pkg/errors" ) const ( attachmentPreviewMaxWidth = 320 attachmentPreviewMaxHeight = 180 + + // using base 10, it will be less confusing for the non-techie users + megabyte = 1_000_000 +) + +var ( + reMimeType = regexp.MustCompile(`\w+/[-.\w]+(?:\+[-.\w]+)?`) ) type ( @@ -48,7 +57,7 @@ type ( FindByID(ctx context.Context, namespaceID, attachmentID uint64) (*types.Attachment, error) Find(ctx context.Context, filter types.AttachmentFilter) (types.AttachmentSet, types.AttachmentFilter, error) CreatePageAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, pageID uint64) (*types.Attachment, error) - CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64) (*types.Attachment, error) + CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64, fieldName string) (*types.Attachment, error) CreateNamespaceAttachment(ctx context.Context, name string, size int64, fh io.ReadSeeker) (*types.Attachment, error) OpenOriginal(att *types.Attachment) (io.ReadSeeker, error) OpenPreview(att *types.Attachment) (io.ReadSeeker, error) @@ -246,8 +255,12 @@ func (svc attachment) CreatePageAttachment(ctx context.Context, namespaceID uint } ) - err = func() error { - ns, p, err = loadPage(ctx, svc.store, namespaceID, pageID) + err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) { + if size == 0 { + return AttachmentErrNotAllowedToCreateEmptyAttachment() + } + + ns, p, err = loadPage(ctx, s, namespaceID, pageID) if err != nil { return err } @@ -259,20 +272,43 @@ func (svc attachment) CreatePageAttachment(ctx context.Context, namespaceID uint return AttachmentErrNotAllowedToUpdatePage() } + { + // Verify size and type of the uploaded page attachment + // Max size & allowed mime-types are pulled from the current settings + var ( + maxSize = int64(systemService.CurrentSettings.Compose.Page.Attachments.MaxSize) * megabyte + allowedTypes = systemService.CurrentSettings.Compose.Page.Attachments.Mimetypes + mimeType string + ) + + if maxSize > 0 && maxSize < size { + return AttachmentErrTooLarge().Apply( + errors.Meta("size", size), + errors.Meta("maxSize", maxSize), + ) + } + + if mimeType, err = svc.extractMimetype(fh); err != nil { + return err + } else if !svc.checkMimeType(mimeType, allowedTypes...) { + return AttachmentErrNotAllowedToUploadThisType() + } + } + att = &types.Attachment{ NamespaceID: namespaceID, Name: strings.TrimSpace(name), Kind: types.PageAttachment, } - return svc.create(ctx, name, size, fh, att) - }() + return svc.create(ctx, s, name, size, fh, att) + }) return att, svc.recordAction(ctx, aProps, AttachmentActionCreate, err) } -func (svc attachment) CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64) (att *types.Attachment, err error) { +func (svc attachment) CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64, fieldName string) (att *types.Attachment, err error) { var ( ns *types.Namespace m *types.Module @@ -286,6 +322,10 @@ func (svc attachment) CreateRecordAttachment(ctx context.Context, namespaceID ui ) err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) { + if size == 0 { + return AttachmentErrNotAllowedToCreateEmptyAttachment() + } + ns, m, err = loadModuleWithNamespace(ctx, s, namespaceID, moduleID) if err != nil { return err @@ -320,13 +360,50 @@ func (svc attachment) CreateRecordAttachment(ctx context.Context, namespaceID ui } } + { + // Verify size and type of the uploaded record attachment + // Max size & allowed mime-types are pulled from the current settings + var ( + maxSize = int64(systemService.CurrentSettings.Compose.Record.Attachments.MaxSize) * megabyte + allowedTypes = systemService.CurrentSettings.Compose.Record.Attachments.Mimetypes + mimeType string + ) + + f := m.Fields.FindByName(fieldName) + if f == nil || f.Kind != "File" { + return AttachmentErrInvalidModuleField().Apply( + errors.Meta("fieldName", fieldName), + ) + } + + if aux := f.Options.Int64("maxSize"); aux > 0 { + maxSize = aux * megabyte + } + if aux := f.Options.String("mimetypes"); len(aux) > 0 { + allowedTypes = strings.Split(aux, ",") + } + + if maxSize > 0 && maxSize < size { + return AttachmentErrTooLarge().Apply( + errors.Meta("size", size), + errors.Meta("maxSize", maxSize), + ) + } + + if mimeType, err = svc.extractMimetype(fh); err != nil { + return err + } else if !svc.checkMimeType(mimeType, allowedTypes...) { + return AttachmentErrNotAllowedToUploadThisType().Apply(errors.Meta("mimetype", mimeType)) + } + } + att = &types.Attachment{ NamespaceID: namespaceID, Name: strings.TrimSpace(name), Kind: types.RecordAttachment, } - return svc.create(ctx, name, size, fh, att) + return svc.create(ctx, s, name, size, fh, att) }) return att, svc.recordAction(ctx, aProps, AttachmentActionCreate, err) @@ -338,23 +415,50 @@ func (svc attachment) CreateNamespaceAttachment(ctx context.Context, name string ) err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) { + if size == 0 { + return AttachmentErrNotAllowedToCreateEmptyAttachment() + } if !svc.ac.CanCreateNamespace(ctx) { return AttachmentErrNotAllowedToUpdateNamespace() } + { + // Verify file size and + var ( + // use max-file-size from page attachments for now + maxSize = int64(systemService.CurrentSettings.Compose.Page.Attachments.MaxSize) * megabyte + mimeType string + ) + + if maxSize > 0 && maxSize < size { + return AttachmentErrTooLarge().Apply( + errors.Meta("size", size), + errors.Meta("maxSize", maxSize), + ) + } + + if mimeType, err = svc.extractMimetype(fh); err != nil { + return err + } else if !svc.checkMimeType(mimeType, "image/png", "image/gif", "image/jpeg") { + return AttachmentErrNotAllowedToUploadThisType() + } + } + att = &types.Attachment{ Name: strings.TrimSpace(name), Kind: types.NamespaceAttachment, } - return svc.create(ctx, name, size, fh, att) + // @todo limit upload on image/* only! + + return svc.create(ctx, s, name, size, fh, att) }) return att, svc.recordAction(ctx, aProps, AttachmentActionCreate, err) } -func (svc attachment) create(ctx context.Context, name string, size int64, fh io.ReadSeeker, att *types.Attachment) (err error) { +func (svc attachment) create(ctx context.Context, s store.ComposeAttachments, name string, size int64, fh io.ReadSeeker, att *types.Attachment) (err error) { var ( aProps = &attachmentActionProps{} ) @@ -368,7 +472,7 @@ func (svc attachment) create(ctx context.Context, name string, size int64, fh io } if svc.objects == nil { - return errors.New("cannot create attachment: store handler not set") + return errors.Internal("cannot create attachment: store handler not set") } if size == 0 { @@ -399,7 +503,7 @@ func (svc attachment) create(ctx context.Context, name string, size int64, fh io return AttachmentErrFailedToProcessImage(aProps).Wrap(err) } - if err = store.CreateComposeAttachment(ctx, svc.store, att); err != nil { + if err = store.CreateComposeAttachment(ctx, s, att); err != nil { return } @@ -451,7 +555,7 @@ func (svc attachment) processImage(original io.ReadSeeker, att *types.Attachment } if format, err = imaging.FormatFromExtension(att.Meta.Original.Extension); err != nil { - return errors.Wrapf(err, "Could not get format from extension '%s'", att.Meta.Original.Extension) + return errors.Internal("could not get format from extension '%s'", att.Meta.Original.Extension).Wrap(err) } previewFormat = format @@ -472,7 +576,7 @@ func (svc attachment) processImage(original io.ReadSeeker, att *types.Attachment // Use first image for the preview preview = cfg.Image[0] } else { - return errors.Wrapf(err, "Could not decode gif config") + return errors.Internal("Could not decode gif config").Wrap(err) } } else { @@ -487,7 +591,7 @@ func (svc attachment) processImage(original io.ReadSeeker, att *types.Attachment // other cases are handled here if preview == nil { if preview, err = imaging.Decode(original); err != nil { - return errors.Wrapf(err, "Could not decode original image") + return errors.Internal("Could not decode original image").Wrap(err) } } @@ -521,4 +625,25 @@ func (svc attachment) processImage(original io.ReadSeeker, att *types.Attachment return svc.objects.Save(att.PreviewUrl, buf) } +func (attachment) checkMimeType(test string, vv ...string) bool { + if len(vv) == 0 { + // return true if there are no type constraints to check against + return true + } + + for _, v := range vv { + v = strings.TrimSpace(v) + + if !reMimeType.MatchString(v) { + continue + } + + if v == test { + return true + } + } + + return false +} + var _ AttachmentService = &attachment{} diff --git a/compose/service/attachment_actions.gen.go b/compose/service/attachment_actions.gen.go index 573362f808..95a85fec3c 100644 --- a/compose/service/attachment_actions.gen.go +++ b/compose/service/attachment_actions.gen.go @@ -11,12 +11,13 @@ package service import ( "context" "fmt" + "strings" + "time" + "github.com/cortezaproject/corteza-server/compose/types" "github.com/cortezaproject/corteza-server/pkg/actionlog" "github.com/cortezaproject/corteza-server/pkg/errors" "github.com/cortezaproject/corteza-server/pkg/locale" - "strings" - "time" ) type ( @@ -781,6 +782,40 @@ func AttachmentErrInvalidModuleID(mm ...*attachmentActionProps) *errors.Error { return e } +// AttachmentErrInvalidModuleField returns "compose:attachment.invalidModuleField" as *errors.Error +// +// +// This function is auto-generated. +// +func AttachmentErrInvalidModuleField(mm ...*attachmentActionProps) *errors.Error { + var p = &attachmentActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("invalid module field", nil), + + errors.Meta("type", "invalidModuleField"), + errors.Meta("resource", "compose:attachment"), + + errors.Meta(attachmentPropsMetaKey{}, p), + + // translation namespace & key + errors.Meta(locale.ErrorMetaNamespace{}, "compose"), + errors.Meta(locale.ErrorMetaKey{}, "attachment.errors.invalidModuleField"), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + // AttachmentErrInvalidPageID returns "compose:attachment.invalidPageID" as *errors.Error // // @@ -849,6 +884,78 @@ func AttachmentErrInvalidRecordID(mm ...*attachmentActionProps) *errors.Error { return e } +// AttachmentErrTooLarge returns "compose:attachment.tooLarge" as *errors.Error +// +// +// This function is auto-generated. +// +func AttachmentErrTooLarge(mm ...*attachmentActionProps) *errors.Error { + var p = &attachmentActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("file too large", nil), + + errors.Meta("type", "tooLarge"), + errors.Meta("resource", "compose:attachment"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(attachmentLogMetaKey{}, "could not upload this file, file size too large"), + errors.Meta(attachmentPropsMetaKey{}, p), + + // translation namespace & key + errors.Meta(locale.ErrorMetaNamespace{}, "compose"), + errors.Meta(locale.ErrorMetaKey{}, "attachment.errors.tooLarge"), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// AttachmentErrNotAllowedToUploadThisType returns "compose:attachment.notAllowedToUploadThisType" as *errors.Error +// +// +// This function is auto-generated. +// +func AttachmentErrNotAllowedToUploadThisType(mm ...*attachmentActionProps) *errors.Error { + var p = &attachmentActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("file type not allowed", nil), + + errors.Meta("type", "notAllowedToUploadThisType"), + errors.Meta("resource", "compose:attachment"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(attachmentLogMetaKey{}, "could not upload this file, file type nto allowed"), + errors.Meta(attachmentPropsMetaKey{}, p), + + // translation namespace & key + errors.Meta(locale.ErrorMetaNamespace{}, "compose"), + errors.Meta(locale.ErrorMetaKey{}, "attachment.errors.notAllowedToUploadThisType"), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + // AttachmentErrNotAllowedToListAttachments returns "compose:attachment.notAllowedToListAttachments" as *errors.Error // // diff --git a/compose/service/attachment_actions.yaml b/compose/service/attachment_actions.yaml index 8b3dff09bb..aa0fc426fa 100644 --- a/compose/service/attachment_actions.yaml +++ b/compose/service/attachment_actions.yaml @@ -85,6 +85,10 @@ errors: message: "invalid module ID" severity: warning + - error: invalidModuleField + message: "invalid module field" + severity: warning + - error: invalidPageID message: "invalid page ID" severity: warning @@ -93,6 +97,14 @@ errors: message: "invalid record ID" severity: warning + - error: tooLarge + message: "file too large" + log: "could not upload this file, file size too large" + + - error: notAllowedToUploadThisType + message: "file type not allowed" + log: "could not upload this file, file type not allowed" + - error: notAllowedToListAttachments message: "not allowed to list attachments" log: "could not list attachments; insufficient permissions" diff --git a/tests/compose/page_test.go b/tests/compose/page_test.go index ee115faf26..cc66408f1b 100644 --- a/tests/compose/page_test.go +++ b/tests/compose/page_test.go @@ -1,10 +1,12 @@ package compose import ( + "bytes" "context" "fmt" "net/http" "net/url" + "os" "testing" "time" @@ -12,6 +14,7 @@ import ( "github.com/cortezaproject/corteza-server/compose/types" "github.com/cortezaproject/corteza-server/pkg/id" "github.com/cortezaproject/corteza-server/store" + systemService "github.com/cortezaproject/corteza-server/system/service" "github.com/cortezaproject/corteza-server/tests/helpers" "github.com/steinfletcher/apitest-jsonpath" "github.com/stretchr/testify/require" @@ -283,6 +286,102 @@ func TestPageTreeRead(t *testing.T) { End() } +func TestPageAttachment(t *testing.T) { + h := newHelper(t) + h.clearPages() + + ns := h.makeNamespace("page attachment testing namespace") + page := h.repoMakePage(ns, "some-page") + + helpers.AllowMe(h, types.NamespaceRbacResource(0), "read") + helpers.AllowMe(h, types.PageRbacResource(0, 0), "read", "update") + + xxlBlob := bytes.Repeat([]byte("0"), 1_000_001) + + testImgFh, err := os.ReadFile("./testdata/test.png") + h.noError(err) + + defer func() { + // reset settings after we're done + systemService.CurrentSettings.Compose.Page.Attachments.MaxSize = 0 + systemService.CurrentSettings.Compose.Page.Attachments.Mimetypes = nil + }() + + // one megabyte limit + systemService.CurrentSettings.Compose.Page.Attachments.MaxSize = 1 + systemService.CurrentSettings.Compose.Page.Attachments.Mimetypes = []string{ + "application/octet-stream", + } + + cc := []struct { + name string + file []byte + fname string + mtype string + form map[string]string + test func(*http.Response, *http.Request) error + }{ + { + "empty file", + []byte(""), + "empty", + "plain/text", + map[string]string{}, + helpers.AssertError("attachment.errors.notAllowedToCreateEmptyAttachment"), + }, + { + "no file", + nil, + "empty", + "plain/text", + map[string]string{}, + helpers.AssertError("attachment.errors.notAllowedToCreateEmptyAttachment"), + }, + { + "valid upload, no constraints", + []byte("."), + "dot", + "plain/text", + map[string]string{}, + helpers.AssertNoErrors, + }, + { + "global max size - over sized", + xxlBlob, + "numbers", + "plain/text", + map[string]string{}, + helpers.AssertError("attachment.errors.tooLarge"), + }, + { + "global mimetype - invalid", + testImgFh, + "numbers.gif", + "image/gif", + map[string]string{}, + helpers.AssertError("attachment.errors.notAllowedToUploadThisType"), + }, + } + + for _, c := range cc { + t.Run(c.name, func(t *testing.T) { + h.t = t + + helpers.InitFileUpload(t, h.apiInit(), + fmt.Sprintf("/namespace/%d/page/%d/attachment", page.NamespaceID, page.ID), + c.form, + c.file, + c.fname, + c.mtype, + ). + Status(http.StatusOK). + Assert(c.test). + End() + + }) + } +} + func TestPageLabels(t *testing.T) { h := newHelper(t) h.clearPages() diff --git a/tests/compose/record_test.go b/tests/compose/record_test.go index 6392399405..72fd96c337 100644 --- a/tests/compose/record_test.go +++ b/tests/compose/record_test.go @@ -8,6 +8,7 @@ import ( "mime/multipart" "net/http" "net/url" + "os" "strconv" "testing" "time" @@ -17,6 +18,7 @@ import ( "github.com/cortezaproject/corteza-server/compose/types" "github.com/cortezaproject/corteza-server/pkg/id" "github.com/cortezaproject/corteza-server/store" + systemService "github.com/cortezaproject/corteza-server/system/service" "github.com/cortezaproject/corteza-server/tests/helpers" "github.com/steinfletcher/apitest" jsonpath "github.com/steinfletcher/apitest-jsonpath" @@ -763,6 +765,172 @@ func TestRecordDelete(t *testing.T) { h.a.NotNil(r.DeletedAt) } +func TestRecordAttachment(t *testing.T) { + h := newHelper(t) + h.clearRecords() + + namespace := h.makeNamespace("record attachment testing namespace") + + helpers.AllowMe(h, types.NamespaceRbacResource(0), "read") + helpers.AllowMe(h, types.ModuleRbacResource(0, 0), "read", "record.create") + helpers.AllowMe(h, types.RecordRbacResource(0, 0, 0), "read") + helpers.AllowMe(h, types.ModuleFieldRbacResource(0, 0, 0), "record.value.read", "record.value.update") + + const maxSizeLimit = 1 + + module := h.makeModule( + namespace, + "module", + &types.ModuleField{Name: "no_constraints", Kind: "File"}, + &types.ModuleField{ + Name: "max_size", + Kind: "File", + Options: types.ModuleFieldOptions{"maxSize": maxSizeLimit}, + }, + &types.ModuleField{ + Name: "img_only", + Kind: "File", + Options: types.ModuleFieldOptions{ + "maxSize": maxSizeLimit, + "mimetypes": "image/gif, image/png, image/jpeg", + }, + }, + &types.ModuleField{Name: "str", Kind: "String"}, + ) + + xxlBlob := bytes.Repeat([]byte("0"), maxSizeLimit*1_000_000+1) + + testImgFh, err := os.ReadFile("./testdata/test.png") + h.noError(err) + + defer func() { + // reset settings after we're done + systemService.CurrentSettings.Compose.Record.Attachments.MaxSize = 0 + systemService.CurrentSettings.Compose.Record.Attachments.Mimetypes = nil + }() + + systemService.CurrentSettings.Compose.Record.Attachments.MaxSize = maxSizeLimit + systemService.CurrentSettings.Compose.Record.Attachments.Mimetypes = []string{ + "application/octet-stream", + } + + cc := []struct { + name string + file []byte + fname string + mtype string + form map[string]string + test func(*http.Response, *http.Request) error + }{ + { + "empty file", + []byte(""), + "empty", + "plain/text", + map[string]string{"fieldName": "no_constraints"}, + helpers.AssertError("attachment.errors.notAllowedToCreateEmptyAttachment"), + }, + { + "no file", + nil, + "empty", + "plain/text", + map[string]string{"fieldName": "no_constraints"}, + helpers.AssertError("attachment.errors.notAllowedToCreateEmptyAttachment"), + }, + { + "no field", + []byte("."), + "dot", + "plain/text", + nil, + helpers.AssertError("attachment.errors.invalidModuleField"), + }, + { + "invalid field", + []byte("."), + "dot", + "plain/text", + map[string]string{"fieldName": "str"}, + helpers.AssertError("attachment.errors.invalidModuleField"), + }, + { + "valid upload, no constraints", + []byte("."), + "dot", + "plain/text", + map[string]string{"fieldName": "no_constraints"}, + helpers.AssertNoErrors, + }, + { + "global max size - over sized", + xxlBlob, + "numbers", + "plain/text", + map[string]string{"fieldName": "no_constraints"}, + helpers.AssertError("attachment.errors.tooLarge"), + }, + { + "field max size - ok", + []byte("12345"), + "numbers", + "plain/text", + map[string]string{"fieldName": "max_size"}, + helpers.AssertNoErrors, + }, + { + "field max size - over sized", + xxlBlob, + "numbers", + "plain/text", + map[string]string{"fieldName": "max_size"}, + helpers.AssertError("attachment.errors.tooLarge"), + }, + { + "global mimetype - invalid", + testImgFh, + "numbers.gif", + "image/gif", + map[string]string{"fieldName": "no_constraints"}, + helpers.AssertError("attachment.errors.notAllowedToUploadThisType"), + }, + { + "field mimetype - ok", + testImgFh, + "image.png", + "image/gif", + map[string]string{"fieldName": "img_only"}, + helpers.AssertNoErrors, + }, + { + "field mimetype - invalid", + testImgFh, + "image.png", + "image/gif", + map[string]string{"fieldName": "img_only"}, + helpers.AssertNoErrors, + }, + } + + for _, c := range cc { + t.Run(c.name, func(t *testing.T) { + h.t = t + + helpers.InitFileUpload(t, h.apiInit(), + fmt.Sprintf("/namespace/%d/module/%d/record/attachment", module.NamespaceID, module.ID), + c.form, + c.file, + c.fname, + c.mtype, + ). + Status(http.StatusOK). + Assert(c.test). + End() + + }) + } +} + func TestRecordExport(t *testing.T) { h := newHelper(t) h.clearRecords() diff --git a/tests/compose/testdata/test.png b/tests/compose/testdata/test.png new file mode 100644 index 0000000000..a0549aa54e Binary files /dev/null and b/tests/compose/testdata/test.png differ diff --git a/tests/helpers/upload.go b/tests/helpers/upload.go new file mode 100644 index 0000000000..762e2c714d --- /dev/null +++ b/tests/helpers/upload.go @@ -0,0 +1,40 @@ +package helpers + +import ( + "bytes" + "fmt" + "mime/multipart" + "net/textproto" + "testing" + + "github.com/steinfletcher/apitest" + "github.com/stretchr/testify/require" +) + +func InitFileUpload(t *testing.T, apiTest *apitest.APITest, endpoint string, form map[string]string, file []byte, name, mimetype string) *apitest.Response { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + for k, v := range form { + require.NoError(t, writer.WriteField(k, v)) + } + + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", + fmt.Sprintf(`form-data; name="%s"; filename="%s"`, + "upload", name)) + header.Set("Content-Type", mimetype) + part, err := writer.CreatePart(header) + require.NoError(t, err) + + _, err = part.Write(file) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + return apiTest. + Post(endpoint). + Body(body.String()). + Header("Content-Type", writer.FormDataContentType()). + Header("Accept", "application/json"). + Expect(t) +} diff --git a/tests/workflows/testdata/attachment_management/data_model.yaml b/tests/workflows/testdata/attachment_management/data_model.yaml index dc4556ab3c..33658d3f16 100644 --- a/tests/workflows/testdata/attachment_management/data_model.yaml +++ b/tests/workflows/testdata/attachment_management/data_model.yaml @@ -6,4 +6,4 @@ modules: mod1: name: Module#1 fields: - f1: { label: 'Field1' } + f1: { label: 'Field1', kind: 'File' } diff --git a/tests/workflows/testdata/attachment_management/workflow.yaml b/tests/workflows/testdata/attachment_management/workflow.yaml index ad4c5c2c48..3283b45f99 100644 --- a/tests/workflows/testdata/attachment_management/workflow.yaml +++ b/tests/workflows/testdata/attachment_management/workflow.yaml @@ -27,9 +27,10 @@ workflows: kind: function ref: attachmentCreate arguments: - - { target: content, type: String, expr: "base64blackGif" } - - { target: name, type: String, value: "black-from-base64-string.gif" } - - { target: resource, type: ComposeRecord, expr: "attachable" } + - { target: content, type: String, expr: "base64blackGif" } + - { target: name, type: String, value: "black-from-base64-string.gif" } + - { target: resource, type: ComposeRecord, expr: "attachable" } + - { target: fieldName, type: String, value: "f1" } results: - { target: storedAttBlackGif, expr: "attachment" }