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 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 67c6a15d..e8d543db 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -953,7 +953,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", @@ -971,6 +974,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 bc206e7e..1c3d27de 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -953,7 +953,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", @@ -971,6 +974,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', ]); /**