From d5314bea1992410adc036175a9d1edac2e0eddd3 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 1 Oct 2026 22:26:35 +0100 Subject: [PATCH 1/2] feat(assets): all-or-nothing CSV import of the inventory (#861) POST /assets/import validates every row (name, type, criticality incl. French spellings, owner), refuses names already in the tenant or repeated in the file, checks the plan cap against the whole file, and writes every asset in one transaction through CreateAssetUseCase. Errors carry the same line/column/code/params contract as the risk import. --- backend/cmd/server/main.go | 13 + .../application/asset/create_asset.go | 9 +- .../application/asset/import_assets.go | 417 ++++++++++++++++++ .../application/asset/import_assets_test.go | 172 ++++++++ .../internal/handler/asset_import_handler.go | 90 ++++ backend/internal/handler/asset_import_test.go | 173 ++++++++ .../repository/gorm_asset_repository.go | 27 ++ 7 files changed, 900 insertions(+), 1 deletion(-) create mode 100644 backend/internal/application/asset/import_assets.go create mode 100644 backend/internal/application/asset/import_assets_test.go create mode 100644 backend/internal/handler/asset_import_handler.go create mode 100644 backend/internal/handler/asset_import_test.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index debbdc29..8e055c58 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1713,6 +1713,19 @@ func main() { // must be registered BEFORE /assets/:id, or "statistics" is parsed as an // asset UUID and the route answers 400 for a request that is perfectly valid. protected.Get("/assets/statistics", assetRead, assetHandler.GetAssetStatistics) + // #861 — CSV import of the inventory: every row or none, one transaction, + // and the plan cap checked against the whole file. + assetImportHandler := handlers.NewAssetImportHandler( + assetapp.NewImportAssetsUseCase(repository.RunAssetTx(database.DB), repository.ListAssetNames(database.DB)). + WithActivation(activationRecorder). + WithCapacity(func(ctx context.Context, tenant uuid.UUID) (int, error) { + _, limit, used, _, err := entitlementService.Capacity(ctx, tenant, ent.LimitAssets) + if err != nil || limit == ent.Unlimited || used < 0 { + return -1, err + } + return max(limit-used, 0), nil + })) + protected.Post("/assets/import", assetCreate, capAssets, assetImportHandler.ImportAssets) protected.Get("/asset-dependencies", assetRead, assetDepHandler.ListAssetDependencies) protected.Post("/asset-dependencies", assetUpdate, assetDepHandler.CreateAssetDependency) protected.Delete("/asset-dependencies/:id", assetUpdate, assetDepHandler.DeleteAssetDependency) diff --git a/backend/internal/application/asset/create_asset.go b/backend/internal/application/asset/create_asset.go index 3913bf57..002070fc 100644 --- a/backend/internal/application/asset/create_asset.go +++ b/backend/internal/application/asset/create_asset.go @@ -25,6 +25,8 @@ type CreateAssetInput struct { // Attributes is the raw, unvalidated bag from the client. It is only ever // persisted after AttributeValidator has checked and coerced it. Attributes map[string]any + // Source records how the asset entered the inventory. Empty means MANUAL. + Source string } // AttributeValidator validates a raw attribute bag against the tenant's schema @@ -97,6 +99,11 @@ func (uc *CreateAssetUseCase) Execute(ctx context.Context, tenantID uuid.UUID, i return nil, domain.NewValidationError("attributes require an asset category — pick one so the values can be validated against its schema") } + source := input.Source + if source == "" { + source = "MANUAL" + } + assetEntity := &domain.Asset{ ID: uuid.New(), TenantID: tenantID, @@ -104,7 +111,7 @@ func (uc *CreateAssetUseCase) Execute(ctx context.Context, tenantID uuid.UUID, i Type: input.Type, Criticality: criticality, Owner: input.Owner, - Source: "MANUAL", + Source: source, Category: category, Attributes: attrs, } diff --git a/backend/internal/application/asset/import_assets.go b/backend/internal/application/asset/import_assets.go new file mode 100644 index 00000000..2277f63e --- /dev/null +++ b/backend/internal/application/asset/import_assets.go @@ -0,0 +1,417 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package asset + +import ( + "bytes" + "context" + "encoding/csv" + "errors" + "fmt" + "io" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" +) + +// --------------------------------------------------------------------------- +// CSV import of the asset inventory (#861). +// +// Same contract as the risk import (#755): every row is validated before +// anything is written, one bad row means nothing is persisted, and every error +// comes back with its line, column, a stable code the page translates, and an +// English message. A valid file is written in ONE transaction through +// CreateAssetUseCase, so an imported asset is born like one typed into the form. +// +// A name already in the tenant's inventory, or repeated in the file, is +// refused: the risk import links assets by name, and re-importing the same +// file must never double the inventory. +// --------------------------------------------------------------------------- + +const ( + // MaxImportRows caps one file, as for risks. + MaxImportRows = 1000 + // MaxImportBytes caps the upload, as for risks. + MaxImportBytes = 2 << 20 + // maxImportNameLen bounds a name, as the form does. + maxImportNameLen = 255 +) + +const ( + importColName = "name" + importColType = "type" + importColCriticality = "criticality" + importColOwner = "owner" +) + +var importColumnAliases = map[string]string{ + "name": importColName, + "nom": importColName, + "type": importColType, + "criticality": importColCriticality, + "criticité": importColCriticality, + "criticite": importColCriticality, + "owner": importColOwner, +} + +// ImportColumns is the accepted header, in template order. +var ImportColumns = []string{importColName, importColType, importColCriticality, importColOwner} + +// importCriticalities maps accepted spellings, upper-cased, to the domain value. +// French spellings are accepted because French-locale teams write them. +var importCriticalities = map[string]domain.AssetCriticality{ + "LOW": domain.CriticalityLow, "FAIBLE": domain.CriticalityLow, + "MEDIUM": domain.CriticalityMedium, "MOYENNE": domain.CriticalityMedium, "MOYEN": domain.CriticalityMedium, + "HIGH": domain.CriticalityHigh, "ÉLEVÉE": domain.CriticalityHigh, "ELEVEE": domain.CriticalityHigh, "ÉLEVÉ": domain.CriticalityHigh, "ELEVE": domain.CriticalityHigh, + "CRITICAL": domain.CriticalityCritical, "CRITIQUE": domain.CriticalityCritical, +} + +// AssetTxRunner runs fn inside one database transaction with an asset +// repository bound to it. An error from fn rolls everything back. +type AssetTxRunner func(ctx context.Context, fn func(repo domain.AssetRepository) error) error + +// ExistingAssetNames lists the names of the tenant's live assets, tenant-scoped. +type ExistingAssetNames func(ctx context.Context, tenantID uuid.UUID) ([]string, error) + +// ImportCapacity reports how many more assets the tenant's plan allows. +// A negative value means unlimited. +type ImportCapacity func(ctx context.Context, tenantID uuid.UUID) (remaining int, err error) + +// ImportAssetsInput is one uploaded file. +type ImportAssetsInput struct { + CSV []byte +} + +// ImportRowError locates one problem in the file; same wire shape as the risk +// import's. Line is 1-based with the header on line 1; 0 means the whole file. +type ImportRowError struct { + Line int `json:"line"` + Column string `json:"column,omitempty"` + Code string `json:"code"` + Params map[string]string `json:"params,omitempty"` + Message string `json:"message"` +} + +// ImportAssetsResult is what the caller is told. +type ImportAssetsResult struct { + Created int `json:"created"` + Rejected int `json:"rejected"` + AssetIDs []uuid.UUID `json:"asset_ids"` + Errors []ImportRowError `json:"errors"` +} + +// ImportRejectedError carries a refused file's result; it matches +// domain.ErrValidation. +type ImportRejectedError struct { + Result *ImportAssetsResult +} + +func (e *ImportRejectedError) Error() string { + return fmt.Sprintf("import rejected: %d error(s), nothing was imported", len(e.Result.Errors)) +} + +func (e *ImportRejectedError) Unwrap() error { return domain.ErrValidation } + +// ImportOverCapacityError means the file would take the tenant past its plan's +// asset limit. Nothing is written. +type ImportOverCapacityError struct { + Requested int + Remaining int +} + +func (e *ImportOverCapacityError) Error() string { + return fmt.Sprintf("import of %d assets exceeds the plan: %d more allowed", e.Requested, e.Remaining) +} + +func (e *ImportOverCapacityError) Unwrap() error { return domain.ErrForbidden } + +// ImportAssetsUseCase imports a CSV file into the tenant's inventory. +type ImportAssetsUseCase struct { + inTx AssetTxRunner + existing ExistingAssetNames + capacity ImportCapacity + activation ActivationRecorder +} + +// NewImportAssetsUseCase builds the use case. existing is required: without +// it a re-import would silently duplicate the inventory. +func NewImportAssetsUseCase(inTx AssetTxRunner, existing ExistingAssetNames) *ImportAssetsUseCase { + return &ImportAssetsUseCase{inTx: inTx, existing: existing} +} + +// WithCapacity attaches the plan-limit check. +func (uc *ImportAssetsUseCase) WithCapacity(c ImportCapacity) *ImportAssetsUseCase { + uc.capacity = c + return uc +} + +// WithActivation attaches the activation recorder, fed after commit only. +func (uc *ImportAssetsUseCase) WithActivation(rec ActivationRecorder) *ImportAssetsUseCase { + uc.activation = rec + return uc +} + +type importAssetRow struct { + line int + input CreateAssetInput +} + +// Execute validates the whole file, then creates every row in one transaction. +func (uc *ImportAssetsUseCase) Execute(ctx context.Context, tenantID uuid.UUID, input ImportAssetsInput) (*ImportAssetsResult, error) { + if tenantID == uuid.Nil { + return nil, domain.NewUnauthorizedError("tenant is required") + } + if len(input.CSV) > MaxImportBytes { + return nil, domain.NewValidationError(fmt.Sprintf("file is larger than %d MB", MaxImportBytes>>20)) + } + + rows, errs := parseAssetCSV(input.CSV) + + // Names already in the inventory. Checked even when other rows failed, so + // the user sees every problem in one pass. + if len(rows) > 0 { + names, err := uc.existing(ctx, tenantID) + if err != nil { + return nil, domain.NewInternalError(fmt.Sprintf("failed to list assets: %v", err)) + } + taken := make(map[string]bool, len(names)) + for _, n := range names { + taken[nameKey(n)] = true + } + for _, r := range rows { + if taken[nameKey(r.input.Name)] { + errs = append(errs, ImportRowError{Line: r.line, Column: importColName, Code: "asset_exists", + Params: map[string]string{"value": r.input.Name}, + Message: fmt.Sprintf("an asset named %q is already in the inventory", r.input.Name)}) + } + } + } + if len(errs) > 0 { + return nil, &ImportRejectedError{Result: rejected(errs)} + } + + if uc.capacity != nil { + remaining, err := uc.capacity(ctx, tenantID) + // A counting error fails open, as the per-create middleware does. + if err == nil && remaining >= 0 && len(rows) > remaining { + return nil, &ImportOverCapacityError{Requested: len(rows), Remaining: remaining} + } + } + + created := make([]*domain.Asset, 0, len(rows)) + err := uc.inTx(ctx, func(repo domain.AssetRepository) error { + create := NewCreateAssetUseCase(repo) + for _, row := range rows { + a, err := create.Execute(ctx, tenantID, row.input) + if err != nil { + var appErr *domain.AppError + if errors.As(err, &appErr) && errors.Is(err, domain.ErrValidation) { + return &ImportRejectedError{Result: rejected([]ImportRowError{{Line: row.line, Code: "rejected_by_rules", Message: appErr.Message}})} + } + return err + } + created = append(created, a) + } + return nil + }) + if err != nil { + var rej *ImportRejectedError + if errors.As(err, &rej) { + return nil, rej + } + return nil, domain.NewInternalError(fmt.Sprintf("import transaction failed: %v", err)) + } + + result := &ImportAssetsResult{ + Created: len(created), + AssetIDs: make([]uuid.UUID, 0, len(created)), + Errors: []ImportRowError{}, + } + for _, a := range created { + result.AssetIDs = append(result.AssetIDs, a.ID) + } + if uc.activation != nil && len(created) > 0 { + uc.activation.Record(ctx, tenantID, string(domain.ActivationAssetConnected), map[string]interface{}{ + "asset_id": created[0].ID.String(), + "source": "IMPORT", + "count": len(created), + }) + } + return result, nil +} + +func nameKey(s string) string { return strings.ToLower(strings.TrimSpace(s)) } + +// rejected builds the result of a refused file: nothing created, every +// offending line counted once. +func rejected(errs []ImportRowError) *ImportAssetsResult { + lines := map[int]bool{} + for _, e := range errs { + if e.Line > 1 { + lines[e.Line] = true + } + } + sort.SliceStable(errs, func(i, j int) bool { return errs[i].Line < errs[j].Line }) + return &ImportAssetsResult{Created: 0, Rejected: len(lines), AssetIDs: []uuid.UUID{}, Errors: errs} +} + +// parseAssetCSV reads and validates the file. With errors it may still return +// the rows it could read, so their names are checked against the inventory in +// the same pass; such a file is never written. +func parseAssetCSV(data []byte) ([]importAssetRow, []ImportRowError) { + data = bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")) // Excel's UTF-8 BOM + if len(bytes.TrimSpace(data)) == 0 { + return nil, []ImportRowError{{Code: "file_empty", Message: "the file is empty"}} + } + if !utf8.Valid(data) { + return nil, []ImportRowError{{Code: "not_utf8", Message: "the file is not UTF-8 text; save it as \"CSV UTF-8\""}} + } + + firstLine, _, _ := bytes.Cut(data, []byte("\n")) + reader := csv.NewReader(bytes.NewReader(data)) + if bytes.Count(firstLine, []byte(";")) > bytes.Count(firstLine, []byte(",")) { + reader.Comma = ';' + } + reader.FieldsPerRecord = -1 + reader.TrimLeadingSpace = true + + header, err := reader.Read() + if err != nil { + return nil, []ImportRowError{{Line: 1, Code: "header_unreadable", Params: map[string]string{"detail": err.Error()}, + Message: fmt.Sprintf("the header cannot be read: %v", err)}} + } + + var errs []ImportRowError + cols := map[string]int{} + for i, h := range header { + raw := strings.TrimSpace(h) + canon, ok := importColumnAliases[strings.ToLower(raw)] + if !ok { + errs = append(errs, ImportRowError{Line: 1, Column: raw, Code: "unknown_column", + Params: map[string]string{"column": raw, "accepted": strings.Join(ImportColumns, ", ")}, + Message: fmt.Sprintf("unknown column %q; accepted columns are %s", raw, strings.Join(ImportColumns, ", "))}) + continue + } + if _, dup := cols[canon]; dup { + errs = append(errs, ImportRowError{Line: 1, Column: raw, Code: "duplicate_column", + Params: map[string]string{"column": canon}, Message: fmt.Sprintf("column %q appears twice", canon)}) + continue + } + cols[canon] = i + } + if _, ok := cols[importColName]; !ok { + errs = append(errs, ImportRowError{Line: 1, Column: importColName, Code: "missing_column", + Params: map[string]string{"column": importColName}, Message: `required column "name" is missing`}) + } + if len(errs) > 0 { + return nil, errs + } + + cell := func(rec []string, col string) string { + i, ok := cols[col] + if !ok || i >= len(rec) { + return "" + } + return strings.TrimSpace(rec[i]) + } + + var rows []importAssetRow + firstLineOf := map[string]int{} + dataRows := 0 + for { + rec, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + line := 0 + var pe *csv.ParseError + if errors.As(err, &pe) { + line = pe.StartLine + } + errs = append(errs, ImportRowError{Line: line, Code: "line_unreadable", Params: map[string]string{"detail": err.Error()}, + Message: fmt.Sprintf("the line cannot be read: %v", err)}) + break // a broken quote can swallow the rest of the file + } + line, _ := reader.FieldPos(0) + if isBlank(rec) { + continue + } + dataRows++ + if dataRows > MaxImportRows { + return nil, []ImportRowError{{Code: "too_many_rows", Params: map[string]string{"max": strconv.Itoa(MaxImportRows)}, + Message: fmt.Sprintf("the file has more than %d rows; split it into several files", MaxImportRows)}} + } + if len(rec) > len(header) { + errs = append(errs, ImportRowError{Line: line, Code: "cell_count", + Params: map[string]string{"cells": strconv.Itoa(len(rec)), "header": strconv.Itoa(len(header))}, + Message: fmt.Sprintf("the line has %d cells but the header has %d", len(rec), len(header))}) + continue + } + + rowOK := true + name := cell(rec, importColName) + switch { + case name == "": + errs = append(errs, ImportRowError{Line: line, Column: importColName, Code: "required", Message: "name is required"}) + rowOK = false + case utf8.RuneCountInString(name) > maxImportNameLen: + errs = append(errs, ImportRowError{Line: line, Column: importColName, Code: "too_long", + Params: map[string]string{"max": strconv.Itoa(maxImportNameLen)}, Message: "name must be 255 characters or less"}) + rowOK = false + default: + if first, dup := firstLineOf[nameKey(name)]; dup { + errs = append(errs, ImportRowError{Line: line, Column: importColName, Code: "duplicate_in_file", + Params: map[string]string{"value": name, "line": strconv.Itoa(first)}, + Message: fmt.Sprintf("%q already appears on line %d", name, first)}) + rowOK = false + } else { + firstLineOf[nameKey(name)] = line + } + } + + var criticality domain.AssetCriticality + if raw := cell(rec, importColCriticality); raw != "" { + c, ok := importCriticalities[strings.ToUpper(raw)] + if !ok { + errs = append(errs, ImportRowError{Line: line, Column: importColCriticality, Code: "invalid_criticality", + Params: map[string]string{"value": raw}, + Message: fmt.Sprintf("%q is not a criticality; use LOW, MEDIUM, HIGH or CRITICAL", raw)}) + rowOK = false + } + criticality = c + } + + if !rowOK { + continue + } + rows = append(rows, importAssetRow{line: line, input: CreateAssetInput{ + Name: name, + Type: cell(rec, importColType), + Criticality: criticality, + Owner: cell(rec, importColOwner), + Source: "IMPORT", + }}) + } + + if dataRows == 0 && len(errs) == 0 { + return nil, []ImportRowError{{Code: "no_rows", Message: "the file has a header but no asset rows"}} + } + return rows, errs +} + +func isBlank(rec []string) bool { + for _, v := range rec { + if strings.TrimSpace(v) != "" { + return false + } + } + return true +} diff --git a/backend/internal/application/asset/import_assets_test.go b/backend/internal/application/asset/import_assets_test.go new file mode 100644 index 00000000..d8493dc8 --- /dev/null +++ b/backend/internal/application/asset/import_assets_test.go @@ -0,0 +1,172 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package asset + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/opendefender/openrisk/internal/domain" +) + +// fakeAssetTx stages creates and only "commits" them when fn succeeds. +type fakeAssetTx struct { + committed []*domain.Asset + failOn int // 1-based create call that errors; 0 never + existing map[uuid.UUID][]string +} + +func (f *fakeAssetTx) run(_ context.Context, fn func(repo domain.AssetRepository) error) error { + var staged []*domain.Asset + calls := 0 + repo := &MockAssetRepository{createFunc: func(_ context.Context, a *domain.Asset) error { + calls++ + if calls == f.failOn { + return errors.New("disk full") + } + staged = append(staged, a) + return nil + }} + if err := fn(repo); err != nil { + return err + } + f.committed = append(f.committed, staged...) + return nil +} + +func (f *fakeAssetTx) names(_ context.Context, tenantID uuid.UUID) ([]string, error) { + return f.existing[tenantID], nil +} + +func importAssets(tx *fakeAssetTx, tenant uuid.UUID, csv string) (*ImportAssetsResult, error) { + return NewImportAssetsUseCase(tx.run, tx.names).Execute(context.Background(), tenant, ImportAssetsInput{CSV: []byte(csv)}) +} + +func assetRejected(t *testing.T, err error) []ImportRowError { + t.Helper() + var rej *ImportRejectedError + require.ErrorAs(t, err, &rej) + require.ErrorIs(t, err, domain.ErrValidation) + assert.Equal(t, 0, rej.Result.Created) + return rej.Result.Errors +} + +func TestImportAssets_Success(t *testing.T) { + tx := &fakeAssetTx{} + tenant := uuid.New() + res, err := importAssets(tx, tenant, "name,type,criticality,owner\n"+ + "Core banking DB,Database,critical,DSI\n"+ + "Kiosk,Laptop,faible,\n"+ + "Web front,Server,,\n") + require.NoError(t, err) + assert.Equal(t, 3, res.Created) + require.Len(t, tx.committed, 3) + + db := tx.committed[0] + assert.Equal(t, tenant, db.TenantID) + assert.Equal(t, "Core banking DB", db.Name) + assert.Equal(t, domain.CriticalityCritical, db.Criticality) + assert.Equal(t, "DSI", db.Owner) + assert.Equal(t, "IMPORT", db.Source) + assert.Equal(t, domain.CriticalityLow, tx.committed[1].Criticality, "French spelling accepted") + assert.Equal(t, domain.CriticalityMedium, tx.committed[2].Criticality, "empty means MEDIUM") +} + +// NotFound: a file with no asset rows finds nothing to import and says so. +func TestImportAssets_NotFound(t *testing.T) { + tx := &fakeAssetTx{} + _, err := importAssets(tx, uuid.New(), "name,type\n\n") + errs := assetRejected(t, err) + assert.Equal(t, "no_rows", errs[0].Code) + assert.Empty(t, tx.committed) +} + +func TestImportAssets_Unauthorized(t *testing.T) { + tx := &fakeAssetTx{} + _, err := importAssets(tx, uuid.Nil, "name\nA\n") + require.ErrorIs(t, err, domain.ErrUnauthorized) + assert.Empty(t, tx.committed) +} + +func TestImportAssets_Validation_OneBadRowImportsNothing(t *testing.T) { + tx := &fakeAssetTx{} + _, err := importAssets(tx, uuid.New(), "name,criticality\n"+ + "Good,HIGH\n"+ + ",LOW\n"+ + "Odd,urgent\n"+ + "good,LOW\n") + errs := assetRejected(t, err) + assert.Empty(t, tx.committed, "a file with an invalid row must persist nothing") + + got := map[string]string{} + for _, e := range errs { + got[fmt.Sprintf("%d:%s", e.Line, e.Column)] = e.Code + } + assert.Equal(t, map[string]string{ + "3:name": "required", + "4:criticality": "invalid_criticality", + "5:name": "duplicate_in_file", + }, got) +} + +func TestImportAssets_ExistingNameIsRefused_OnlyInSameTenant(t *testing.T) { + tenant, other := uuid.New(), uuid.New() + tx := &fakeAssetTx{existing: map[uuid.UUID][]string{tenant: {"Web front"}, other: {"Kiosk"}}} + + _, err := importAssets(tx, tenant, "name\nweb FRONT\nKiosk\n") + errs := assetRejected(t, err) + require.Len(t, errs, 1, "another tenant's names do not count") + assert.Equal(t, 2, errs[0].Line) + assert.Equal(t, "asset_exists", errs[0].Code) + assert.Empty(t, tx.committed) +} + +func TestImportAssets_WriteFailureRollsBackEverything(t *testing.T) { + tx := &fakeAssetTx{failOn: 2} + _, err := importAssets(tx, uuid.New(), "name\nA\nB\nC\n") + require.Error(t, err) + assert.Empty(t, tx.committed) +} + +func TestImportAssets_HeaderAndFileErrors(t *testing.T) { + for name, tc := range map[string]struct{ csv, code string }{ + "unknown column": {"name,colour\nA,red\n", "unknown_column"}, + "missing name": {"type\nServer\n", "missing_column"}, + "empty": {"", "file_empty"}, + "not utf8": {"name\n\xff\xfe\n", "not_utf8"}, + } { + t.Run(name, func(t *testing.T) { + tx := &fakeAssetTx{} + _, err := importAssets(tx, uuid.New(), tc.csv) + assert.Equal(t, tc.code, assetRejected(t, err)[0].Code) + }) + } +} + +func TestImportAssets_FrenchSpreadsheetExport(t *testing.T) { + tx := &fakeAssetTx{} + res, err := importAssets(tx, uuid.New(), "\xef\xbb\xbfnom;type;criticité\nPoste RH;Laptop;Élevée\n") + require.NoError(t, err) + assert.Equal(t, 1, res.Created) + assert.Equal(t, domain.CriticalityHigh, tx.committed[0].Criticality) +} + +func TestImportAssets_OverCapacityWritesNothing(t *testing.T) { + tx := &fakeAssetTx{} + _, err := NewImportAssetsUseCase(tx.run, tx.names). + WithCapacity(func(context.Context, uuid.UUID) (int, error) { return 1, nil }). + Execute(context.Background(), uuid.New(), ImportAssetsInput{CSV: []byte("name\nA\nB\n")}) + var over *ImportOverCapacityError + require.ErrorAs(t, err, &over) + assert.Equal(t, 2, over.Requested) + assert.Empty(t, tx.committed) +} diff --git a/backend/internal/handler/asset_import_handler.go b/backend/internal/handler/asset_import_handler.go new file mode 100644 index 00000000..243883e4 --- /dev/null +++ b/backend/internal/handler/asset_import_handler.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package handler + +import ( + "errors" + "io" + "path/filepath" + "strings" + + "github.com/gofiber/fiber/v2" + + assetuc "github.com/opendefender/openrisk/internal/application/asset" +) + +// AssetImportHandler serves POST /assets/import (#861). +type AssetImportHandler struct { + uc *assetuc.ImportAssetsUseCase +} + +func NewAssetImportHandler(uc *assetuc.ImportAssetsUseCase) *AssetImportHandler { + return &AssetImportHandler{uc: uc} +} + +// ImportAssets POST /assets/import — multipart field "file", a CSV. +// +// 200 {created, rejected, asset_ids, errors: []} when every row was written. +// 422 {error, message, created: 0, rejected, errors: [{line, column, code, params, message}]} +// when any row is invalid: nothing was written. +// 402 limit_reached when the file would take the tenant past its plan. +// +// Same contract as POST /risks/import. Tenant comes from the signed session only. +func (h *AssetImportHandler) ImportAssets(c *fiber.Ctx) error { + file, err := c.FormFile("file") + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "validation_failed", "message": "Attach a CSV file in the \"file\" field.", + }) + } + if !strings.EqualFold(filepath.Ext(file.Filename), ".csv") { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "validation_failed", "message": "Only CSV files can be imported.", + }) + } + if file.Size > assetuc.MaxImportBytes { + return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{ + "error": "validation_failed", "message": "The file is larger than 2 MB. Split it into several files.", + }) + } + f, err := file.Open() + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "validation_failed", "message": "The file cannot be read."}) + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, assetuc.MaxImportBytes+1)) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "validation_failed", "message": "The file cannot be read."}) + } + + result, err := h.uc.Execute(c.UserContext(), tenantID(c), assetuc.ImportAssetsInput{CSV: data}) + if err != nil { + var rej *assetuc.ImportRejectedError + if errors.As(err, &rej) { + return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{ + "error": "validation_failed", + "message": "Nothing was imported. Fix the lines below and import the file again.", + "created": rej.Result.Created, + "rejected": rej.Result.Rejected, + "asset_ids": rej.Result.AssetIDs, + "errors": rej.Result.Errors, + }) + } + var over *assetuc.ImportOverCapacityError + if errors.As(err, &over) { + return c.Status(fiber.StatusPaymentRequired).JSON(fiber.Map{ + "code": "limit_reached", + "limit_key": "assets", + "requested": over.Requested, + "remaining": over.Remaining, + "message": "This file would take you past your plan's asset limit. Nothing was imported.", + "upgrade_url": "/settings?tab=billing", + }) + } + return writeAppError(c, err) + } + return c.JSON(result) +} diff --git a/backend/internal/handler/asset_import_test.go b/backend/internal/handler/asset_import_test.go new file mode 100644 index 00000000..881531ee --- /dev/null +++ b/backend/internal/handler/asset_import_test.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package handler + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + assetuc "github.com/opendefender/openrisk/internal/application/asset" + "github.com/opendefender/openrisk/internal/domain" + "github.com/opendefender/openrisk/internal/infrastructure/repository" + "github.com/opendefender/openrisk/internal/middleware" + "github.com/opendefender/openrisk/internal/testsupport/sqliteschema" +) + +// assetImportApp mounts POST /assets/import as main.go does — behind +// assets:create — over a real sqlite database, so the transaction is real. +type assetImportApp struct { + app *fiber.App + db *gorm.DB + tenant *uuid.UUID + perms *[]string +} + +func newAssetImportApp(t *testing.T) *assetImportApp { + t.Helper() + dsn := "file:asset_import_" + uuid.New().String() + "?mode=memory&cache=private" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.Exec(`CREATE TABLE assets (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, name TEXT NOT NULL, + criticality TEXT NOT NULL DEFAULT 'MEDIUM', created_at DATETIME, updated_at DATETIME, deleted_at DATETIME)`).Error) + require.NoError(t, sqliteschema.Reconcile(db, "assets", &domain.Asset{})) + + h := &assetImportApp{db: db, tenant: new(uuid.UUID), perms: &[]string{"assets:create"}} + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + middleware.SetContext(c, &middleware.RequestContext{UserID: uuid.New(), OrganizationID: *h.tenant}) + c.Locals("permissions", *h.perms) + return c.Next() + }) + handler := NewAssetImportHandler(assetuc.NewImportAssetsUseCase(repository.RunAssetTx(db), repository.ListAssetNames(db))) + app.Post("/api/v1/assets/import", middleware.RequirePermission("assets:create"), handler.ImportAssets) + h.app = app + return h +} + +func (h *assetImportApp) upload(t *testing.T, filename, content string) (int, map[string]any) { + t.Helper() + var body bytes.Buffer + w := multipart.NewWriter(&body) + part, err := w.CreateFormFile("file", filename) + require.NoError(t, err) + _, _ = part.Write([]byte(content)) + require.NoError(t, w.Close()) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/assets/import", &body) + req.Header.Set("Content-Type", w.FormDataContentType()) + resp, err := h.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + var decoded map[string]any + _ = json.Unmarshal(raw, &decoded) + return resp.StatusCode, decoded +} + +func (h *assetImportApp) assetsOf(t *testing.T, tenant uuid.UUID) []domain.Asset { + t.Helper() + var out []domain.Asset + require.NoError(t, h.db.Where("tenant_id = ?", tenant).Order("name").Find(&out).Error) + return out +} + +const validAssetImport = "name,type,criticality,owner\n" + + "Core banking DB,Database,CRITICAL,DSI\n" + + "Kiosk,Laptop,low,\n" + +func TestAssetImportHTTP_Success(t *testing.T) { + h := newAssetImportApp(t) + tenant := uuid.New() + *h.tenant = tenant + + status, body := h.upload(t, "inventory.csv", validAssetImport) + require.Equal(t, fiber.StatusOK, status, "%v", body) + require.EqualValues(t, 2, body["created"]) + require.Len(t, body["asset_ids"], 2) + + rows := h.assetsOf(t, tenant) + require.Len(t, rows, 2) + require.Equal(t, "Core banking DB", rows[0].Name) + require.Equal(t, domain.CriticalityCritical, rows[0].Criticality) + require.Equal(t, "IMPORT", rows[0].Source) + require.Equal(t, domain.CriticalityLow, rows[1].Criticality) + + // Re-importing the same file never doubles the inventory. + status, body = h.upload(t, "inventory.csv", validAssetImport) + require.Equal(t, fiber.StatusUnprocessableEntity, status, "%v", body) + require.Len(t, h.assetsOf(t, tenant), 2) +} + +func TestAssetImportHTTP_Validation_NothingPersisted(t *testing.T) { + h := newAssetImportApp(t) + tenant := uuid.New() + *h.tenant = tenant + + status, body := h.upload(t, "inventory.csv", validAssetImport+"Printer,Device,urgent,\n") + require.Equal(t, fiber.StatusUnprocessableEntity, status, "%v", body) + require.EqualValues(t, 0, body["created"]) + errs, _ := body["errors"].([]any) + require.Len(t, errs, 1) + first, _ := errs[0].(map[string]any) + require.EqualValues(t, 4, first["line"]) + require.Equal(t, "criticality", first["column"]) + require.Equal(t, "invalid_criticality", first["code"]) + require.Empty(t, h.assetsOf(t, tenant), "one invalid row means zero rows persisted") +} + +func TestAssetImportHTTP_Unauthorized(t *testing.T) { + h := newAssetImportApp(t) + tenant := uuid.New() + *h.tenant = tenant + *h.perms = []string{"assets:read"} + + status, _ := h.upload(t, "inventory.csv", validAssetImport) + require.Equal(t, fiber.StatusForbidden, status) + require.Empty(t, h.assetsOf(t, tenant)) +} + +func TestAssetImportHTTP_NotFound_NoFile(t *testing.T) { + h := newAssetImportApp(t) + *h.tenant = uuid.New() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/assets/import", nil) + resp, err := h.app.Test(req, -1) + require.NoError(t, err) + require.Equal(t, fiber.StatusBadRequest, resp.StatusCode) + + status, _ := h.upload(t, "inventory.xlsx", validAssetImport) + require.Equal(t, fiber.StatusBadRequest, status, "only CSV is accepted") +} + +func TestAssetImportHTTP_RowsLandOnlyInCallersTenant(t *testing.T) { + h := newAssetImportApp(t) + tenantA, tenantB := uuid.New(), uuid.New() + + *h.tenant = tenantB + status, body := h.upload(t, "b.csv", "name\nKiosk\n") + require.Equal(t, fiber.StatusOK, status, "%v", body) + + // Tenant B already has "Kiosk"; that must not block tenant A's own. + *h.tenant = tenantA + status, body = h.upload(t, "a.csv", validAssetImport) + require.Equal(t, fiber.StatusOK, status, "%v", body) + + require.Len(t, h.assetsOf(t, tenantA), 2) + bRows := h.assetsOf(t, tenantB) + require.Len(t, bRows, 1, "tenant A's import must not land in tenant B") + require.Equal(t, "Kiosk", bRows[0].Name) +} diff --git a/backend/internal/infrastructure/repository/gorm_asset_repository.go b/backend/internal/infrastructure/repository/gorm_asset_repository.go index a47dfc3d..c9cd8e3d 100644 --- a/backend/internal/infrastructure/repository/gorm_asset_repository.go +++ b/backend/internal/infrastructure/repository/gorm_asset_repository.go @@ -38,6 +38,33 @@ func (r *GormAssetRepository) Create(ctx context.Context, asset *domain.Asset) e return r.db.WithContext(ctx).Create(asset).Error } +// RunAssetTx runs fn inside one transaction with an asset repository bound to +// it, for the CSV import that writes every asset or none (#861). It satisfies +// application/asset.AssetTxRunner. +func RunAssetTx(db *gorm.DB) func(ctx context.Context, fn func(repo domain.AssetRepository) error) error { + return func(ctx context.Context, fn func(repo domain.AssetRepository) error) error { + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return fn(NewGormAssetRepository(tx)) + }) + } +} + +// ListAssetNames lists the names of a tenant's live assets, so the import can +// refuse a name the inventory already holds (#861). It satisfies +// application/asset.ExistingAssetNames. +func ListAssetNames(db *gorm.DB) func(ctx context.Context, tenantID uuid.UUID) ([]string, error) { + return func(ctx context.Context, tenantID uuid.UUID) ([]string, error) { + if tenantID == uuid.Nil { + return nil, fmt.Errorf("tenant_id is required") + } + var names []string + err := db.WithContext(ctx).Model(&domain.Asset{}). + Where("tenant_id = ?", tenantID). + Pluck("name", &names).Error + return names, err + } +} + // GetByID retrieves an asset by ID scoped to a tenant, with linked risks preloaded. func (r *GormAssetRepository) GetByID(ctx context.Context, id uuid.UUID, tenantID uuid.UUID) (*domain.Asset, error) { var asset domain.Asset From 0f9b817fd334f7854b653472683b6a3fbd7fa64b Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 1 Oct 2026 22:32:09 +0100 Subject: [PATCH 2/2] feat(assets): import page for the inventory CSV (#861) /assets/import reuses the shared CSV import page with its own template, column help and FR/EN catalogue strings, and is reachable from an Import button on the inventory. The three asset-specific error codes are added to the catalogue. --- frontend/src/App.tsx | 4 + .../src/features/assets/ImportAssetsPage.tsx | 46 +++++++ .../src/features/assets/InventoryPage.tsx | 8 ++ .../assets/__tests__/importAssets.test.tsx | 115 ++++++++++++++++++ .../features/assets/importAssetsTemplate.ts | 12 ++ frontend/src/features/assets/useAssets.ts | 2 +- frontend/src/locales/en.json | 20 ++- frontend/src/locales/fr.json | 20 ++- .../src/shared/csvImport/csvImportSchema.ts | 6 +- 9 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 frontend/src/features/assets/ImportAssetsPage.tsx create mode 100644 frontend/src/features/assets/__tests__/importAssets.test.tsx create mode 100644 frontend/src/features/assets/importAssetsTemplate.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54d356ae..48dea259 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -146,6 +146,9 @@ const RemediationPage = lazy(() => const InventoryPage = lazy(() => import('./features/assets/InventoryPage').then((m) => ({ default: m.InventoryPage })), ); +const ImportAssetsPage = lazy(() => + import('./features/assets/ImportAssetsPage').then((m) => ({ default: m.ImportAssetsPage })), +); const QuestionnaireTemplatesPage = lazy(() => import('./features/tprm/QuestionnaireTemplatesPage').then((m) => ({ default: m.QuestionnaireTemplatesPage, @@ -702,6 +705,7 @@ function App() { {/* ---------------- Assets ---------------- */} } /> + } /> {/* The Asset Universe was superseded by the topology view (same graph, plus zoom/pan, zone clustering, typed edges, compromise chain and export). Kept as a redirect so existing links and bookmarks land diff --git a/frontend/src/features/assets/ImportAssetsPage.tsx b/frontend/src/features/assets/ImportAssetsPage.tsx new file mode 100644 index 00000000..1a79cf01 --- /dev/null +++ b/frontend/src/features/assets/ImportAssetsPage.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +// CSV import of the asset inventory (#861). The page is the shared +// CsvImportPage, with the same all-or-nothing contract as the risk import. + +import { useQueryClient } from '@tanstack/react-query'; + +import { useI18n } from '../../hooks/useI18n'; +import { CsvImportPage, type CsvImportConfig } from '../../shared/csvImport/CsvImportPage'; +import { ASSETS_QUERY_KEY } from './useAssets'; +import { ASSET_IMPORT_TEMPLATE } from './importAssetsTemplate'; + +export const ImportAssetsPage = () => { + const { t } = useI18n(); + const queryClient = useQueryClient(); + + const config: CsvImportConfig = { + endpoint: '/assets/import', + back: { to: '/assets', label: t('csvImport.assets.back') }, + title: t('csvImport.assets.title'), + intro: t('csvImport.assets.intro'), + columns: [ + { name: 'name', help: t('csvImport.assets.colName') }, + { name: 'criticality', help: t('csvImport.assets.colCriticality') }, + { name: 'type, owner', help: t('csvImport.assets.colOptional') }, + ], + note: t('csvImport.assets.note'), + template: ASSET_IMPORT_TEMPLATE, + templateFilename: 'openrisk-assets-template.csv', + open: { to: '/assets', label: t('csvImport.assets.open') }, + created: (count) => t('csvImport.assets.created', { count }), + emptyFile: t('csvImport.assets.emptyFile'), + forbidden: t('csvImport.assets.forbidden'), + limitTitle: t('csvImport.assets.limitTitle'), + limitDetail: (requested, remaining) => + t('csvImport.assets.limitDetail', { requested, remaining }), + onCreated: () => void queryClient.invalidateQueries({ queryKey: ASSETS_QUERY_KEY }), + }; + + return ; +}; + +export default ImportAssetsPage; diff --git a/frontend/src/features/assets/InventoryPage.tsx b/frontend/src/features/assets/InventoryPage.tsx index 0bc62157..130c10ca 100644 --- a/frontend/src/features/assets/InventoryPage.tsx +++ b/frontend/src/features/assets/InventoryPage.tsx @@ -25,6 +25,7 @@ import { History, Pencil, Trash2, + Upload, type LucideIcon, } from 'lucide-react'; import { PageFrame, PageHeader, Btn, CritBadge, EmptyState } from '../../shared/ui'; @@ -364,6 +365,13 @@ export function InventoryPage() { icon={Atom} onClick={() => navigate('/assets/topology')} /> + {canCreate && ( + navigate('/assets/import')} + /> + )} {canCreate && ( ({ + api: { post: (...a: unknown[]) => post(...a), defaults: { baseURL: '' } }, +})); +vi.mock('../../../hooks/useToast', () => ({ + useToast: () => ({ success: toastSuccess, error: vi.fn(), promise: vi.fn() }), +})); + +import { ImportAssetsPage } from '../ImportAssetsPage'; +import { useUIStore } from '../../../store/uiStore'; + +function httpError(status: number, data: unknown): AxiosError { + const response = { + status, + data, + statusText: '', + headers: {}, + config: { headers: new AxiosHeaders() }, + } as AxiosResponse; + return new AxiosError('request failed', String(status), undefined, undefined, response); +} + +async function chooseAndImport() { + const user = userEvent.setup(); + const client = new QueryClient(); + const invalidate = vi.spyOn(client, 'invalidateQueries'); + render( + + + + + , + ); + await user.upload( + screen.getByTestId('import-file-input'), + new File(['name\nA\n'], 'inventory.csv', { type: 'text/csv' }), + ); + await user.click(screen.getByRole('button', { name: /^(import|importer)$/i })); + return invalidate; +} + +describe('ImportAssetsPage', () => { + beforeEach(() => { + useUIStore.getState().setLang('en'); + post.mockReset(); + toastSuccess.mockReset(); + }); + + it('posts to /assets/import, shows the count and refreshes the inventory', async () => { + post.mockResolvedValue({ + data: { created: 2, rejected: 0, asset_ids: ['a', 'b'], errors: [] }, + }); + const invalidate = await chooseAndImport(); + + expect(await screen.findByText('2 asset(s) imported.')).toBeInTheDocument(); + expect(post.mock.calls[0]?.[0]).toBe('/assets/import'); + expect(toastSuccess).toHaveBeenCalledTimes(1); + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['assets'] }); + }); + + it('lists asset-specific errors in French and shows no success', async () => { + useUIStore.getState().setLang('fr'); + post.mockRejectedValue( + httpError(422, { + created: 0, + rejected: 2, + errors: [ + { + line: 2, + column: 'name', + code: 'asset_exists', + params: { value: 'Kiosk' }, + message: 'an asset named "Kiosk" is already in the inventory', + }, + { + line: 3, + column: 'criticality', + code: 'invalid_criticality', + params: { value: 'urgent' }, + message: '"urgent" is not a criticality', + }, + ], + }), + ); + await chooseAndImport(); + + const panel = await screen.findByTestId('import-outcome-error'); + expect(panel).toHaveTextContent('Un actif « Kiosk » existe déjà dans l’inventaire.'); + expect(panel).toHaveTextContent('« urgent » n’est pas une criticité'); + expect(toastSuccess).not.toHaveBeenCalled(); + }); + + it('explains the plan’s asset limit', async () => { + post.mockRejectedValue(httpError(402, { code: 'limit_reached', requested: 40, remaining: 5 })); + await chooseAndImport(); + + const panel = await screen.findByTestId('import-outcome-error'); + expect(panel).toHaveTextContent('The file has 40 asset(s); your plan allows 5 more.'); + }); +}); diff --git a/frontend/src/features/assets/importAssetsTemplate.ts b/frontend/src/features/assets/importAssetsTemplate.ts new file mode 100644 index 00000000..7f0f61af --- /dev/null +++ b/frontend/src/features/assets/importAssetsTemplate.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +/** The current template. Names must be new to the inventory. */ +export const ASSET_IMPORT_TEMPLATE = [ + 'name,type,criticality,owner', + '"Core banking database",Database,CRITICAL,"IT department"', + '"Customer web portal",Server,HIGH,', + '"HR laptop fleet",Laptop,MEDIUM,"HR"', +].join('\n'); diff --git a/frontend/src/features/assets/useAssets.ts b/frontend/src/features/assets/useAssets.ts index ea61a464..9211e649 100644 --- a/frontend/src/features/assets/useAssets.ts +++ b/frontend/src/features/assets/useAssets.ts @@ -8,7 +8,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { assetService, type AssetSearchFilter } from '../../services/assetService'; import type { Asset, CreateAssetInput, UpdateAssetInput } from '../../types/asset'; -const ASSETS_QUERY_KEY = ['assets']; +export const ASSETS_QUERY_KEY = ['assets']; const historyQueryKey = (assetId: string) => ['assets', assetId, 'history']; /** diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 5d5fd503..4560fec6 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -963,7 +963,10 @@ "unknown_asset": "No asset named \"{value}\" in the inventory.", "ambiguous_asset": "{count} assets are named \"{value}\": use its id instead.", "assets_unavailable": "Linking assets is not available on this server: remove the assets column.", - "legacy_scale": "This file uses the old 1–5 scale. OpenRisk expects probability between 0 and 1 and impact between 0 and 10. Download the current template and convert the values (probability 3/5 → 0.6, impact 4/5 → 8)." + "legacy_scale": "This file uses the old 1–5 scale. OpenRisk expects probability between 0 and 1 and impact between 0 and 10. Download the current template and convert the values (probability 3/5 → 0.6, impact 4/5 → 8).", + "asset_exists": "An asset named \"{value}\" is already in the inventory.", + "duplicate_in_file": "\"{value}\" already appears on line {line}.", + "invalid_criticality": "\"{value}\" is not a criticality: use LOW, MEDIUM, HIGH or CRITICAL." }, "risks": { "back": "Risk register", @@ -981,6 +984,21 @@ "forbidden": "You are not allowed to create risks.", "limitTitle": "Nothing was imported: this file exceeds your plan’s risk limit.", "limitDetail": "The file has {requested} risk(s); your plan allows {remaining} more." + }, + "assets": { + "back": "Inventory", + "title": "Import assets", + "intro": "A CSV file, one row per asset. Every row is checked before anything is imported: if a single row is invalid, nothing is imported and every error is listed.", + "colName": "Required, at most 255 characters, not already in the inventory and unique in the file.", + "colCriticality": "Optional: LOW, MEDIUM, HIGH or CRITICAL. MEDIUM when empty.", + "colOptional": "Optional, free text.", + "note": "Excel exports using \";\" are accepted. Typed attributes are filled in afterwards in the inventory. Once imported, the risk file can name these assets in its assets column.", + "open": "Open the inventory", + "created": "{count} asset(s) imported", + "emptyFile": "The file contained no assets.", + "forbidden": "You are not allowed to add assets.", + "limitTitle": "Nothing was imported: this file exceeds your plan’s asset limit.", + "limitDetail": "The file has {requested} asset(s); your plan allows {remaining} more." } } } diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 78c5de84..ef801f22 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -963,7 +963,10 @@ "unknown_asset": "Aucun actif « {value} » dans l’inventaire.", "ambiguous_asset": "{count} actifs s’appellent « {value} » : indiquez son identifiant.", "assets_unavailable": "La liaison aux actifs n’est pas disponible sur ce serveur : retirez la colonne assets.", - "legacy_scale": "Ce fichier utilise l’ancienne échelle 1–5. OpenRisk attend une probabilité entre 0 et 1 et un impact entre 0 et 10. Téléchargez le modèle actuel et convertissez les valeurs (probabilité 3/5 → 0,6 ; impact 4/5 → 8)." + "legacy_scale": "Ce fichier utilise l’ancienne échelle 1–5. OpenRisk attend une probabilité entre 0 et 1 et un impact entre 0 et 10. Téléchargez le modèle actuel et convertissez les valeurs (probabilité 3/5 → 0,6 ; impact 4/5 → 8).", + "asset_exists": "Un actif « {value} » existe déjà dans l’inventaire.", + "duplicate_in_file": "« {value} » figure déjà ligne {line}.", + "invalid_criticality": "« {value} » n’est pas une criticité : LOW, MEDIUM, HIGH ou CRITICAL (ou FAIBLE, MOYENNE, ÉLEVÉE, CRITIQUE)." }, "risks": { "back": "Registre des risques", @@ -981,6 +984,21 @@ "forbidden": "Vous n’avez pas le droit de créer des risques.", "limitTitle": "Rien n’a été importé : ce fichier dépasse la limite de risques de votre plan.", "limitDetail": "Le fichier contient {requested} risque(s) ; votre plan en permet encore {remaining}." + }, + "assets": { + "back": "Inventaire", + "title": "Importer des actifs", + "intro": "Un fichier CSV, une ligne par actif. Toutes les lignes sont vérifiées avant l’import : si une seule est invalide, rien n’est importé et chaque erreur vous est indiquée.", + "colName": "Obligatoire, 255 caractères au plus, absent de l’inventaire et unique dans le fichier.", + "colCriticality": "Facultatif : LOW, MEDIUM, HIGH ou CRITICAL (ou FAIBLE, MOYENNE, ÉLEVÉE, CRITIQUE). MEDIUM par défaut.", + "colOptional": "Facultatifs, texte libre.", + "note": "Les exports Excel en « ; » sont acceptés. Les attributs typés se renseignent ensuite dans l’inventaire. Une fois les actifs importés, le fichier de risques peut les citer dans sa colonne assets.", + "open": "Voir l’inventaire", + "created": "{count} actif(s) importé(s)", + "emptyFile": "Le fichier ne contenait aucun actif.", + "forbidden": "Vous n’avez pas le droit d’ajouter des actifs.", + "limitTitle": "Rien n’a été importé : ce fichier dépasse la limite d’actifs de votre plan.", + "limitDetail": "Le fichier contient {requested} actif(s) ; votre plan en permet encore {remaining}." } } } diff --git a/frontend/src/shared/csvImport/csvImportSchema.ts b/frontend/src/shared/csvImport/csvImportSchema.ts index 7a8b8725..39e91164 100644 --- a/frontend/src/shared/csvImport/csvImportSchema.ts +++ b/frontend/src/shared/csvImport/csvImportSchema.ts @@ -3,7 +3,8 @@ // This program is free software: you can redistribute it and/or modify it under // the terms of the GNU Affero General Public License v3.0 (see LICENSE). // -// Contract of the CSV imports, starting with POST /risks/import (#755). The server is the authority on +// Contract of the CSV imports: POST /risks/import (#755) and POST +// /assets/import (#861) answer the same shapes. The server is the authority on // every row; the client only refuses what it can know without reading the file, // and parses every response so the page never shows a number it was not sent. @@ -54,6 +55,9 @@ const KNOWN_CODES = new Set([ 'ambiguous_asset', 'assets_unavailable', 'legacy_scale', + 'asset_exists', + 'duplicate_in_file', + 'invalid_criticality', ]); /**