diff --git a/backend/app/api/handlers/v1/v1_ctrl_integration_proxy.go b/backend/app/api/handlers/v1/v1_ctrl_integration_proxy.go new file mode 100644 index 000000000..6e4b981c7 --- /dev/null +++ b/backend/app/api/handlers/v1/v1_ctrl_integration_proxy.go @@ -0,0 +1,128 @@ +package v1 + +import ( + "fmt" + "io" + "net/http" + "path" + "regexp" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/hay-kot/httpkit/errchain" + "github.com/rs/zerolog/log" + "github.com/sysadminsmedia/homebox/backend/internal/core/services" + "github.com/sysadminsmedia/homebox/backend/internal/sys/validate" + "go.opentelemetry.io/otel/attribute" +) + +// validIntegrationName restricts integration names to safe lower-case identifiers, +// preventing settings-key injection (e.g. "../../evil"). +var validIntegrationName = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`) + +// HandleIntegrationProxy godoc +// +// @Summary Integration Reverse Proxy +// @Description Proxies a single GET request to the configured external integration. +// The integration's credentials (base URL + API token) are read from +// user settings ({name}_url / {name}_token) and never exposed to the +// frontend. This single generic endpoint replaces all per-integration +// proxy handlers: adding a new integration only requires a Vue component +// and a settings entry — no new Go code. +// @Tags Integrations +// @Produce */* +// @Param name path string true "Integration name, e.g. paperless" +// @Param path query string true "Relative API path on the upstream service, must start with /" +// @Success 200 +// @Failure 400 {object} validate.ErrorResponse +// @Failure 502 {object} validate.ErrorResponse +// @Router /v1/integrations/{name}/proxy [GET] +// @Security Bearer +func (ctrl *V1Controller) HandleIntegrationProxy() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + spanCtx, span := startEntityCtrlSpan(r.Context(), "controller.V1.HandleIntegrationProxy") + defer span.End() + + name := chi.URLParam(r, "name") + if !validIntegrationName.MatchString(name) { + return validate.NewRequestError(fmt.Errorf("invalid integration name"), http.StatusBadRequest) + } + + rawPath := r.URL.Query().Get("path") + if rawPath == "" { + return validate.NewRequestError(fmt.Errorf("path query parameter is required"), http.StatusBadRequest) + } + if !strings.HasPrefix(rawPath, "/") || strings.Contains(rawPath, "://") { + return validate.NewRequestError(fmt.Errorf("path must be a relative path starting with /"), http.StatusBadRequest) + } + + // Normalise to prevent directory traversal while preserving trailing slash + // (many REST APIs treat /foo/1/ and /foo/1 differently). + cleanPath := path.Clean(rawPath) + if !strings.HasPrefix(cleanPath, "/") { + return validate.NewRequestError(fmt.Errorf("invalid path after normalisation"), http.StatusBadRequest) + } + if strings.HasSuffix(rawPath, "/") && !strings.HasSuffix(cleanPath, "/") { + cleanPath += "/" + } + + span.SetAttributes( + attribute.String("integration.name", name), + attribute.String("integration.path", cleanPath), + ) + + ctx := services.NewContext(spanCtx) + settings, svcErr := ctrl.svc.User.GetSettings(ctx.Context, services.UseUserCtx(ctx.Context).ID) + if svcErr != nil { + return validate.NewRequestError(svcErr, http.StatusInternalServerError) + } + + baseURL, _ := settings[name+"_url"].(string) + if baseURL == "" { + return validate.NewRequestError( + fmt.Errorf("%s_url not configured – add it in Settings", name), + http.StatusBadRequest, + ) + } + + token, _ := settings[name+"_token"].(string) + if token == "" { + return validate.NewRequestError( + fmt.Errorf("%s_token not configured – add it in Settings", name), + http.StatusBadRequest, + ) + } + + upstream := strings.TrimRight(baseURL, "/") + cleanPath + + req, err := http.NewRequest(http.MethodGet, upstream, nil) + if err != nil { + return validate.NewRequestError(err, http.StatusBadRequest) + } + req.Header.Set("Authorization", "Token "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Err(err).Str("integration", name).Str("upstream", upstream).Msg("integration proxy: upstream request failed") + return validate.NewRequestError(err, http.StatusBadGateway) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return validate.NewRequestError(fmt.Errorf("resource not found at upstream"), http.StatusNotFound) + } + if resp.StatusCode >= 400 { + return validate.NewRequestError( + fmt.Errorf("upstream returned %d", resp.StatusCode), + http.StatusBadGateway, + ) + } + + if ct := resp.Header.Get("Content-Type"); ct != "" { + w.Header().Set("Content-Type", ct) + } + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, resp.Body) + return nil + } +} diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 473470ffe..301d29afb 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -207,6 +207,9 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR r.Delete("/notifiers/{id}", chain.ToHandlerFunc(v1Ctrl.HandleDeleteNotifier(), userMW...)) r.Post("/notifiers/test", chain.ToHandlerFunc(v1Ctrl.HandlerNotifierTest(), append(userMW, a.notifierTestLimiter.middleware)...)) + // Integration proxy endpoints + r.Get("/integrations/{name}/proxy", chain.ToHandlerFunc(v1Ctrl.HandleIntegrationProxy(), userMW...)) + // Asset-Like endpoints assetMW := []errchain.Middleware{ a.mwAuthToken, diff --git a/backend/go.sum b/backend/go.sum index bb664ea62..b125519eb 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -135,12 +135,6 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= -github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= @@ -338,8 +332,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -362,14 +354,6 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/olahol/melody v1.4.0 h1:Pa5SdeZL/zXPi1tJuMAPDbl4n3gQOThSL6G1p4qZ4SI= github.com/olahol/melody v1.4.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4= -github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= -github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= -github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= -github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= -github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= -github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= -github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= -github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= @@ -413,10 +397,6 @@ github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfx github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/backend/internal/core/services/service_items_attachments_external_test.go b/backend/internal/core/services/service_items_attachments_external_test.go index 29e0aca5b..13ed61cff 100644 --- a/backend/internal/core/services/service_items_attachments_external_test.go +++ b/backend/internal/core/services/service_items_attachments_external_test.go @@ -30,103 +30,154 @@ func newExternalLinkEntity(t *testing.T) repo.EntityOut { return entity } -func TestEntityService_AttachmentAddExternalLink_DefaultType(t *testing.T) { - svc := &EntityService{repo: tRepos} - entity := newExternalLinkEntity(t) +// knownSources lists every registered external-link source type together with a +// representative external ID. To add or remove a service integration from the +// test matrix, update this slice only — all table-driven tests pick up the +// change automatically. +var knownSources = []struct { + sourceType string + externalID string +}{ + {"paperless", "42"}, + {"immich", "1df4f848-dead-beef-cafe-123456789abc"}, + {"link", "https://example.com/doc"}, +} - out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "link", "https://example.com/doc/42", "Example Doc", "") - require.NoError(t, err) - require.NotEmpty(t, out.Attachments) +// TestEntityService_AttachmentAddExternalLink_SourceTypes verifies that every +// known source type is accepted and that the stored mimeType and path match the +// contract defined by repo.MimeTypeForSourceType. +func TestEntityService_AttachmentAddExternalLink_SourceTypes(t *testing.T) { + svc := &EntityService{repo: tRepos} - var found bool - for _, att := range out.Attachments { - if att.Path == "https://example.com/doc/42" { - found = true - assert.Equal(t, repo.MimeTypeLinkURL, att.MimeType) - assert.Equal(t, "Example Doc", att.Title) - assert.Equal(t, string(attachment.TypeAttachment), att.Type) - } + for _, src := range knownSources { + t.Run(src.sourceType, func(t *testing.T) { + entity := newExternalLinkEntity(t) + + expectedMime, ok := repo.MimeTypeForSourceType(src.sourceType) + require.True(t, ok, "knownSources entry %q has no registered mime type", src.sourceType) + + out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, src.sourceType, src.externalID, "Test Doc", attachment.TypeAttachment) + require.NoError(t, err) + require.NotEmpty(t, out.Attachments) + + var found bool + for _, att := range out.Attachments { + if att.Path == src.externalID { + found = true + assert.Equal(t, expectedMime, att.MimeType) + assert.Equal(t, "Test Doc", att.Title) + assert.Equal(t, string(attachment.TypeAttachment), att.Type) + } + } + assert.True(t, found, "expected attachment with path %q in entity output", src.externalID) + }) } - assert.True(t, found) } -func TestEntityService_AttachmentAddExternalLink_ManualType(t *testing.T) { - svc := &EntityService{repo: tRepos} - entity := newExternalLinkEntity(t) +// TestEntityService_AttachmentAddExternalLink_AttachmentTypes verifies that all +// attachment type variants (Manual, Warranty, Receipt) are stored correctly. +// The source-type matrix is already covered by +// TestEntityService_AttachmentAddExternalLink_SourceTypes, so a single +// representative source type is sufficient here. +func TestEntityService_AttachmentAddExternalLink_AttachmentTypes(t *testing.T) { + src := knownSources[0] + expectedMime, _ := repo.MimeTypeForSourceType(src.sourceType) + + cases := []struct { + attType attachment.Type + title string + }{ + {attachment.TypeManual, "Manual"}, + {attachment.TypeWarranty, "Warranty"}, + {attachment.TypeReceipt, "Receipt"}, + } - out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "link", "https://example.com/manual", "Manual", attachment.TypeManual) - require.NoError(t, err) + svc := &EntityService{repo: tRepos} - var found bool - for _, att := range out.Attachments { - if att.Path == "https://example.com/manual" { - found = true - assert.Equal(t, string(attachment.TypeManual), att.Type) - } + for _, tc := range cases { + t.Run(string(tc.attType), func(t *testing.T) { + entity := newExternalLinkEntity(t) + + out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, src.sourceType, src.externalID, tc.title, tc.attType) + require.NoError(t, err) + + var found bool + for _, att := range out.Attachments { + if att.Path == src.externalID { + found = true + assert.Equal(t, string(tc.attType), att.Type) + assert.Equal(t, expectedMime, att.MimeType) + } + } + assert.True(t, found) + }) } - assert.True(t, found) } -func TestEntityService_AttachmentAddExternalLink_WarrantyType(t *testing.T) { +// TestEntityService_AttachmentAddExternalLink_MultipleAttachments verifies that +// multiple external-link attachments (one per registered source type) can coexist +// on a single entity. +func TestEntityService_AttachmentAddExternalLink_MultipleAttachments(t *testing.T) { svc := &EntityService{repo: tRepos} entity := newExternalLinkEntity(t) - out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "link", "https://example.com/warranty", "Warranty", attachment.TypeWarranty) - require.NoError(t, err) - - var found bool - for _, att := range out.Attachments { - if att.Path == "https://example.com/warranty" { - found = true - assert.Equal(t, string(attachment.TypeWarranty), att.Type) - } + for _, src := range knownSources { + _, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, src.sourceType, src.externalID, src.sourceType+" doc", attachment.TypeAttachment) + require.NoError(t, err, "failed for source type %q", src.sourceType) } - assert.True(t, found) + + latest, err := svc.repo.Entities.GetOneByGroup(tCtx, tCtx.GID, entity.ID) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(latest.Attachments), len(knownSources)) } -func TestEntityService_AttachmentAddExternalLink_ReceiptType(t *testing.T) { +// TestEntityService_AttachmentAddExternalLink_NoBlobStorage verifies that +// adding an external-link attachment does not trigger any blob-storage +// operations (which would fail without a real storage backend). +func TestEntityService_AttachmentAddExternalLink_NoBlobStorage(t *testing.T) { svc := &EntityService{repo: tRepos} entity := newExternalLinkEntity(t) + src := knownSources[0] - out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "link", "https://example.com/receipt", "Receipt", attachment.TypeReceipt) + out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, src.sourceType, src.externalID, "No Blob Doc", attachment.TypeAttachment) require.NoError(t, err) - - var found bool - for _, att := range out.Attachments { - if att.Path == "https://example.com/receipt" { - found = true - assert.Equal(t, string(attachment.TypeReceipt), att.Type) - } - } - assert.True(t, found) + require.NotEmpty(t, out.Attachments) } +// TestEntityService_AttachmentAddExternalLink_InvalidEntity verifies that +// using a non-existent entity ID returns an error. func TestEntityService_AttachmentAddExternalLink_InvalidEntity(t *testing.T) { svc := &EntityService{repo: tRepos} + src := knownSources[0] - _, err := svc.AttachmentAddExternalLink(tCtx, uuid.New(), "link", "https://example.com/missing", "Missing", attachment.TypeAttachment) + _, err := svc.AttachmentAddExternalLink(tCtx, uuid.New(), src.sourceType, src.externalID, "Missing", attachment.TypeAttachment) assert.Error(t, err) } +// TestEntityService_AttachmentAddExternalLink_UnknownSourceType verifies that +// an unregistered source type is rejected before any DB write. func TestEntityService_AttachmentAddExternalLink_UnknownSourceType(t *testing.T) { svc := &EntityService{repo: tRepos} entity := newExternalLinkEntity(t) - _, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "paperless", "42", "Paperless", attachment.TypeAttachment) + _, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "unknown-source", "42", "Unknown", attachment.TypeAttachment) assert.Error(t, err) } +// TestEntityService_AttachmentDelete_ExternalLink verifies that an external-link +// attachment can be deleted and is no longer retrievable afterwards. func TestEntityService_AttachmentDelete_ExternalLink(t *testing.T) { svc := &EntityService{repo: tRepos} entity := newExternalLinkEntity(t) + src := knownSources[0] - out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "link", "https://example.com/delete", "Delete Me", attachment.TypeAttachment) + out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, src.sourceType, src.externalID, "Delete Me", attachment.TypeAttachment) require.NoError(t, err) require.NotEmpty(t, out.Attachments) var createdID uuid.UUID for _, att := range out.Attachments { - if att.Path == "https://example.com/delete" { + if att.Path == src.externalID { createdID = att.ID break } diff --git a/backend/internal/data/repo/repo_item_attachments.go b/backend/internal/data/repo/repo_item_attachments.go index db8f5914d..9638037fa 100644 --- a/backend/internal/data/repo/repo_item_attachments.go +++ b/backend/internal/data/repo/repo_item_attachments.go @@ -84,16 +84,32 @@ type ( } ) +// MimeTypeLinkURL is the MIME type for generic HTTP/HTTPS URL links. const MimeTypeLinkURL = "link/url" +// MimeTypePaperlessDocument is the MIME type for Paperless-ngx document links. +const MimeTypePaperlessDocument = "paperless/document" + +// MimeTypeImmichAsset is the MIME type for Immich asset links. +const MimeTypeImmichAsset = "immich/asset" + var externalLinkMimeTypes = []string{ MimeTypeLinkURL, + MimeTypePaperlessDocument, + MimeTypeImmichAsset, } +// MimeTypeForSourceType maps a user-facing integration source-type name (e.g. "paperless") +// to the internal MIME discriminator stored in the attachments table. +// Returns ("", false) for unknown source types. func MimeTypeForSourceType(sourceType string) (string, bool) { switch sourceType { case "link": return MimeTypeLinkURL, true + case "paperless": + return MimeTypePaperlessDocument, true + case "immich": + return MimeTypeImmichAsset, true default: return "", false } diff --git a/backend/internal/data/repo/repo_item_attachments_test.go b/backend/internal/data/repo/repo_item_attachments_test.go index 1bcfbfd61..9456eac34 100644 --- a/backend/internal/data/repo/repo_item_attachments_test.go +++ b/backend/internal/data/repo/repo_item_attachments_test.go @@ -16,10 +16,22 @@ import ( ) func TestMimeTypeForSourceType(t *testing.T) { + // Test "link" source type mime, ok := MimeTypeForSourceType("link") assert.True(t, ok) assert.Equal(t, MimeTypeLinkURL, mime) + // Test "paperless" source type + mime, ok = MimeTypeForSourceType("paperless") + assert.True(t, ok) + assert.Equal(t, MimeTypePaperlessDocument, mime) + + // Test "immich" source type + mime, ok = MimeTypeForSourceType("immich") + assert.True(t, ok) + assert.Equal(t, MimeTypeImmichAsset, mime) + + // Test unknown source type mime, ok = MimeTypeForSourceType("unknown") assert.False(t, ok) assert.Empty(t, mime) @@ -138,44 +150,103 @@ func TestAttachmentRepo_Delete(t *testing.T) { require.Error(t, err) } -func TestAttachmentRepo_CreateExternalLink(t *testing.T) { +// externalLinkMimeTypeCases covers every registered external-link MIME type. +// Adding a new integration only requires a new entry here — all table-driven +// tests below pick it up automatically. +var externalLinkMimeTypeCases = []struct { + mimeType string + externalID string +}{ + {MimeTypeLinkURL, "https://example.com/doc"}, + {MimeTypePaperlessDocument, "42"}, + {MimeTypeImmichAsset, "1df4f848-dead-beef-cafe-123456789abc"}, +} + +// TestAttachmentRepo_CreateExternalLink_PerMimeType verifies that path, title, +// and mimeType are stored and returned verbatim for every known external-link +// MIME type. +func TestAttachmentRepo_CreateExternalLink_PerMimeType(t *testing.T) { ctx := context.Background() - entity := useEntities(t, 1)[0] - att, err := tRepos.Attachments.CreateExternalLink( - ctx, - entity.ID, - "https://example.com/manual", - "Example Manual", - MimeTypeLinkURL, + for _, tc := range externalLinkMimeTypeCases { + t.Run(tc.mimeType, func(t *testing.T) { + entity := useEntities(t, 1)[0] + + att, err := tRepos.Attachments.CreateExternalLink(ctx, entity.ID, tc.externalID, "Test Doc", tc.mimeType, attachment.TypeAttachment) + require.NoError(t, err) + require.NotNil(t, att) + + t.Cleanup(func() { _ = tRepos.Attachments.Delete(ctx, tGroup.ID, att.ID) }) + + assert.Equal(t, tc.externalID, att.Path) + assert.Equal(t, "Test Doc", att.Title) + assert.Equal(t, tc.mimeType, att.MimeType) + assert.Equal(t, attachment.TypeAttachment, att.Type) + assert.False(t, att.Primary) + }) + } +} + +// TestAttachmentRepo_CreateExternalLink_AttachmentTypes verifies that all +// attachment type variants are stored correctly. A single representative MIME +// type is sufficient since the MIME type coverage is handled above. +func TestAttachmentRepo_CreateExternalLink_AttachmentTypes(t *testing.T) { + ctx := context.Background() + mimeType := externalLinkMimeTypeCases[0].mimeType + externalID := externalLinkMimeTypeCases[0].externalID + + types := []attachment.Type{ + attachment.TypeAttachment, attachment.TypeManual, - ) + attachment.TypeWarranty, + attachment.TypeReceipt, + } + + for _, typ := range types { + t.Run(string(typ), func(t *testing.T) { + entity := useEntities(t, 1)[0] + + att, err := tRepos.Attachments.CreateExternalLink(ctx, entity.ID, externalID, "Doc", mimeType, typ) + require.NoError(t, err) + t.Cleanup(func() { _ = tRepos.Attachments.Delete(ctx, tGroup.ID, att.ID) }) + + assert.Equal(t, typ, att.Type) + assert.Equal(t, mimeType, att.MimeType) + }) + } +} + +// TestAttachmentRepo_CreateExternalLink_EmptyTypeDefaultsToAttachment verifies +// that passing an empty attachment type string results in TypeAttachment. +func TestAttachmentRepo_CreateExternalLink_EmptyTypeDefaultsToAttachment(t *testing.T) { + ctx := context.Background() + tc := externalLinkMimeTypeCases[0] + entity := useEntities(t, 1)[0] + + att, err := tRepos.Attachments.CreateExternalLink(ctx, entity.ID, tc.externalID, "Test", tc.mimeType, "") require.NoError(t, err) - require.NotNil(t, att) + t.Cleanup(func() { _ = tRepos.Attachments.Delete(ctx, tGroup.ID, att.ID) }) - t.Cleanup(func() { - _ = tRepos.Attachments.Delete(ctx, tGroup.ID, att.ID) - }) + assert.Equal(t, attachment.TypeAttachment, att.Type) +} + +// TestAttachmentRepo_CreateExternalLink_InvalidEntityID verifies that using a +// non-existent entity ID returns an error. +func TestAttachmentRepo_CreateExternalLink_InvalidEntityID(t *testing.T) { + tc := externalLinkMimeTypeCases[0] - assert.Equal(t, "https://example.com/manual", att.Path) - assert.Equal(t, "Example Manual", att.Title) - assert.Equal(t, MimeTypeLinkURL, att.MimeType) - assert.Equal(t, attachment.TypeManual, att.Type) - assert.False(t, att.Primary) + _, err := tRepos.Attachments.CreateExternalLink(context.Background(), uuid.New(), tc.externalID, "Orphan", tc.mimeType, attachment.TypeAttachment) + assert.Error(t, err) } +// TestAttachmentRepo_DeleteExternalLink verifies that a created external-link +// attachment can be deleted and is no longer retrievable. func TestAttachmentRepo_DeleteExternalLink(t *testing.T) { ctx := context.Background() entity := useEntities(t, 1)[0] + tc := externalLinkMimeTypeCases[0] - att, err := tRepos.Attachments.CreateExternalLink( - ctx, - entity.ID, - "https://example.com/receipt", - "Example Receipt", - MimeTypeLinkURL, - attachment.TypeReceipt, - ) + att, err := tRepos.Attachments.CreateExternalLink(ctx, entity.ID, tc.externalID, "Delete Me", tc.mimeType, attachment.TypeAttachment) require.NoError(t, err) err = tRepos.Attachments.Delete(ctx, tGroup.ID, att.ID) @@ -185,24 +256,22 @@ func TestAttachmentRepo_DeleteExternalLink(t *testing.T) { require.Error(t, err) } +// TestAttachmentRepo_DeleteExternalLink_DoesNotRequireBlobStorage verifies that +// deleting an external-link attachment does not attempt blob-storage I/O. func TestAttachmentRepo_DeleteExternalLink_DoesNotRequireBlobStorage(t *testing.T) { ctx := context.Background() - repos := New(tClient, tbus, config.Storage{PrefixPath: "/", ConnString: "mem://"}, "mem://{{ .Topic }}", config.Thumbnail{Enabled: false}) entity := useEntities(t, 1)[0] + tc := externalLinkMimeTypeCases[0] - att, err := repos.Attachments.CreateExternalLink( - ctx, - entity.ID, - "https://example.com/no-blob", - "No Blob", - MimeTypeLinkURL, - attachment.TypeAttachment, - ) + att, err := repos.Attachments.CreateExternalLink(ctx, entity.ID, tc.externalID, "No Blob", tc.mimeType, attachment.TypeAttachment) require.NoError(t, err) err = repos.Attachments.Delete(ctx, tGroup.ID, att.ID) require.NoError(t, err) + + _, err = tRepos.Attachments.Get(ctx, tGroup.ID, att.ID) + assert.Error(t, err) } func TestAttachmentRepo_EnsureSinglePrimaryAttachment(t *testing.T) { diff --git a/frontend/components/Item/AttachmentsList.vue b/frontend/components/Item/AttachmentsList.vue index 5140821cc..ade7c89ae 100644 --- a/frontend/components/Item/AttachmentsList.vue +++ b/frontend/components/Item/AttachmentsList.vue @@ -1,68 +1,181 @@