diff --git a/demo/console/assessment-results-compare.tape b/demo/console/assessment-results-compare.tape new file mode 100644 index 00000000..db753b82 --- /dev/null +++ b/demo/console/assessment-results-compare.tape @@ -0,0 +1,45 @@ +Output images/assessment-results-console-compare.gif + +Require lula +Set FontSize 14 +Set Width 1850 +Set Height 925 +Set Framerate 24 +Set Padding 5 + +Hide +Type "lula console -f ./src/test/unit/common/oscal/valid-assessment-results-multi.yaml" Enter +Sleep 1s +Tab +Sleep 500ms +Show + +# Select comparison result +Sleep 1s +Right +Sleep 500ms +Right +Sleep 500ms +Enter +Sleep 1s +Down +Sleep 500ms +Enter +Sleep 500ms + +# Navigate to findings, observations +Right +Sleep 500ms +Right +Sleep 500ms +Down +Sleep 500ms +Up +Sleep 500ms +Left +Sleep 1s + +# Show findings comparison detail +Type "d" +Sleep 2s + diff --git a/demo/console/assessment-results.tape b/demo/console/assessment-results.tape new file mode 100644 index 00000000..f256756a --- /dev/null +++ b/demo/console/assessment-results.tape @@ -0,0 +1,49 @@ +Output images/assessment-results-console.gif + +Require lula +Set FontSize 14 +Set Width 1850 +Set Height 925 +Set Framerate 24 +Set Padding 5 + +Hide +Type "lula console -f ./src/test/unit/common/oscal/valid-assessment-results-multi.yaml" Enter +Sleep 1s +Tab +Sleep 500ms +Show + +# Show results picker +Sleep 1s +Right +Sleep 500ms +Enter +Sleep 1s +Down +Sleep 500ms +Enter +Sleep 500ms + +# Navigate to findings, filter +Right +Sleep 500ms +Right +Sleep 500ms +Down +Sleep 500ms +Up +Sleep 500ms +Type "/" +Sleep 500ms +Type "ID-1" +Sleep 500ms +Enter +Sleep 1s + +# Navigate to observations +Right +Sleep 500ms +Type "d" +Sleep 2s + diff --git a/demo/console/component-validation-detail.tape b/demo/console/component-validation-detail.tape new file mode 100644 index 00000000..8a9d1b1a --- /dev/null +++ b/demo/console/component-validation-detail.tape @@ -0,0 +1,39 @@ +Output images/component-defn-console-validation-detail.gif + +Require lula +Set FontSize 14 +Set Width 1850 +Set Height 925 +Set Framerate 24 +Set Padding 5 + +Hide +Type "lula console -f ./src/test/unit/common/oscal/valid-multi-component-validations.yaml" Enter +Sleep 1s +Show + +# Navigate to a control and select +Right +Sleep 500ms +Right +Sleep 500ms +Right +Sleep 500ms +Enter +Sleep 1s +Right +Sleep 500ms +Right +Sleep 500ms +Right +Sleep 500ms +Type "d" +Sleep 1s +Down +Sleep 500ms +Down +Sleep 1s +Up +Sleep 500ms +Up +Sleep 1s diff --git a/docs/console/README.md b/docs/console/README.md index 6e913e4d..42de8786 100644 --- a/docs/console/README.md +++ b/docs/console/README.md @@ -2,7 +2,38 @@ The Lula Console is a text-based terminal user interface that allows users to interact with the OSCAL documents in a more intuitive and visual way. -See the sub-pages for more information on interacting with OSCAL models in the Console. +Currently, only the **Component Definition** and **Assessment Results** models are supported in the Console. + + * See the sub-pages for more information on interacting with each specific OSCAL model in the Console. >[!NOTE] ->The Console is currently in development and is subject to change. \ No newline at end of file +>The Console is currently in development and views are subject to change. + +## Usage + +To open the Console with particular OSCAL models: +```shell +lula console -f /path/to/oscal-component.yaml,/path/to/oscal-component-2.yaml,/path/to/assessment-results.yaml +``` +The `-f` (or `--input-files`) flag can be used to specify multiple OSCAL model file paths to be loaded into the Console. + +### Writing to Output +Currently, the Console supports only writing to the `component-definition` model. + +To include an output file to save any changes made to the component definition, use the `--component-output` or `-c`flag: +```shell +lula console -f /path/to/oscal-component.yaml -c /path/to/output.yaml +``` + +If no output file is specified and a single component definition is passed, the provided component definition will be overwritten. If multiple component definitions are passed and no output file is specified, the Console will default to `component.yaml` in the current working directory. + +## Keys + +The Console responds to the following keys for navigation and interaction (each sub-model has additional key response, see respective help views for more information): + +| Key | Description | +|-----|-------------| +| `?` | Toggle help | +| `ctrl+c` | Quit | +| `tab` | Tab right between models | +| `shift+tab` | Tab left between models | \ No newline at end of file diff --git a/docs/console/assessment-results.md b/docs/console/assessment-results.md new file mode 100644 index 00000000..46d57367 --- /dev/null +++ b/docs/console/assessment-results.md @@ -0,0 +1,40 @@ +# Assessment Results + +The Assessment Result view provides the Console user with an interactive experience of the OSCAL Assessment Results model. The view supports navigation between the different results contained in the Assessment Results model, as well as the ability to view Findings and Observations in tabular and filterable formats. Additional `detail` views are available to view the raw OSCAL model data for selected Findings/Observations. + +## Usage + +The Assessment Results model supports the following views: + * [Result View](./assessment-results.md#result-view) + * [Result Comparison View](./assessment-results.md#result-comparison-view) + +## Keys + +The Assessment Results model responds to the following keys for navigation and interaction (some widgets have additional key response, see respective help views for more information): + +| Key | Description | +|-----|-------------| +| `?` | Toggle help | +| `ctrl+c` | Quit | +| `tab` | Tab right between models | +| `shift+tab` | Tab left between models | +| `←/h` | Navigate left across widgets in model| +| `→/l` | Navigate right across widgets model | +| `↑/k` | Move up in table OR scroll up in panel | +| `↓/j` | Move down in table OR scroll up in panel | +| `/` | Filter table | +| `↳` | Select available item | +| `d` | Detail available item (findings and observations) | +| `esc` | Close OR esc filtering | + +During console viewing, the top-right corner will display the help keys availble in the context of the selected widget. When an overlay is open, the help keys will be displayed in the overlay. + +## Views + +### Result View + +assessment results console + +### Result Comparison View + +assessment results console comparison diff --git a/docs/console/component-definition.md b/docs/console/component-definition.md index 67edce28..0a5013a4 100644 --- a/docs/console/component-definition.md +++ b/docs/console/component-definition.md @@ -4,19 +4,10 @@ The Component Definition view currently allows for a read and limited write expe ## Usage -To view an OSCAL Component Definition model in the Console: -```shell -lula console -f /path/to/oscal-component.yaml -``` -The `oscal-component.yaml` will need to be a valid OSCAL model - to use with the Component Definition view, it must contain the `component-definition` top level key. - -To include an output file to save any changes made to the component definition, use the `--component-output` or `-c`flag: -```shell -lula console -f /path/to/oscal-component.yaml -c /path/to/output.yaml -``` - -> [!Note] -> Several component definition models can be passed into the console, via `-f` in a comma-separated list. For multiple component definitions, the output file will default to `component.yaml` unless specified. +The Component Definition model supports the following views: + * [Read-Only Navigation](./component-definition.md#read-only-navigation) + * [Editing Remarks and Description](./component-definition.md#editing-remarks-and-description) + * [Validation Detail](./component-definition.md#validation-detail) ## Keys @@ -28,13 +19,14 @@ The Component Definition model responds to the following keys for navigation and | `ctrl+c` | Quit | | `tab` | Tab right between models | | `shift+tab` | Tab left between models | -| `←/h` | Navigate left in model| -| `→/l` | Navigate right in model | +| `←/h` | Navigate left across widgets model| +| `→/l` | Navigate right across widgets model | | `↑/k` | Move up in list OR scroll up in panel | | `↓/j` | Move down in list OR scroll up in panel | | `/` | Filter list | | `↳` | Select item | | `e` | Edit available fields (remarks and description) | +| `d` | Detail available fields (validations) | | `ctrl+s` | Save changes (Note: this may overwrite the original file if an output file unspecified) | | `esc` | Cancel | @@ -44,8 +36,18 @@ During console viewing, the top-right corner will display the help keys availble ### Read-Only Navigation +The model can be sorted by Component, Framework, and Control. Additional data/features provided by the Component Definition OSCAL Model are not currently supported for viewing in the Console. + component definition console read ### Editing Remarks and Description +Limited editing of the remarks and description is supported. Once changes are made, to be persisted back to the file, the data will need to be saved via the `ctrl+s` key. + component definition console edit + +### Validation Detail + +The Validation Detail is a view that displays a somewhat curated version of the Lula Validation. It is intended to be a quick view of the validation, and is not a one-to-one representation. + +component definition console validation detail diff --git a/go.mod b/go.mod index 00918510..0ba03510 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/defenseunicorns/go-oscal v0.6.0 github.com/defenseunicorns/pkg/kubernetes v0.3.0 + github.com/evertras/bubble-table v0.17.0 github.com/google/go-cmp v0.6.0 github.com/hashicorp/go-version v1.7.0 github.com/kyverno/kyverno-json v0.0.3 @@ -117,6 +118,7 @@ require ( github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/opencontainers/go-digest v1.0.0 // indirect diff --git a/go.sum b/go.sum index b3193f91..cf9fc530 100644 --- a/go.sum +++ b/go.sum @@ -143,6 +143,8 @@ github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lSh github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/evertras/bubble-table v0.17.0 h1:qQU4bi3IRxuZ5+Fvm3esyU/ucH9ufRXWhWL0fFuMn9c= +github.com/evertras/bubble-table v0.17.0/go.mod h1:ifHujS1YxwnYSOgcR2+m3GnJ84f7CVU/4kUOxUCjEbQ= github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwCFad8crR9dcMQWvV9Hvulu6hwUh4tWPJnM= github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -304,6 +306,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -335,6 +338,8 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -384,6 +389,7 @@ github.com/pterm/pterm v0.12.79 h1:lH3yrYMhdpeqX9y5Ep1u7DejyHy7NSQg9qrBjF9dFT4= github.com/pterm/pterm v0.12.79/go.mod h1:1v/gzOF1N0FsjbgTHZ1wVycRkKiatFvJSJC4IGaQAAo= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/images/assessment-results-console-compare.gif b/images/assessment-results-console-compare.gif new file mode 100644 index 00000000..fddf871a Binary files /dev/null and b/images/assessment-results-console-compare.gif differ diff --git a/images/assessment-results-console.gif b/images/assessment-results-console.gif new file mode 100644 index 00000000..b753d069 Binary files /dev/null and b/images/assessment-results-console.gif differ diff --git a/images/component-defn-console-validation-detail.gif b/images/component-defn-console-validation-detail.gif new file mode 100644 index 00000000..c2c6ae6e Binary files /dev/null and b/images/component-defn-console-validation-detail.gif differ diff --git a/src/internal/tui/assessment_results/assessment-results.go b/src/internal/tui/assessment_results/assessment-results.go index b7c02064..0aa59e7e 100644 --- a/src/internal/tui/assessment_results/assessment-results.go +++ b/src/internal/tui/assessment_results/assessment-results.go @@ -3,319 +3,265 @@ package assessmentresults import ( "fmt" "regexp" + "slices" "strings" + "time" - "github.com/charmbracelet/bubbles/key" - blist "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" "github.com/defenseunicorns/lula/src/internal/tui/common" + "github.com/defenseunicorns/lula/src/pkg/common/oscal" + pkgResult "github.com/defenseunicorns/lula/src/pkg/common/result" + "github.com/evertras/bubble-table/table" ) -const ( - height = 20 - width = 12 - pickerHeight = 20 - pickerWidth = 80 - dialogFixedWidth = 40 +var ( + satisfiedColors = map[string]lipgloss.Style{ + "satisfied": lipgloss.NewStyle().Foreground(lipgloss.Color("#3ad33c")), + "not-satisfied": lipgloss.NewStyle().Foreground(lipgloss.Color("#e36750")), + "other": lipgloss.NewStyle().Foreground(lipgloss.Color("#f3f3f3")), + } ) -func NewAssessmentResultsModel(assessmentResults *oscalTypes_1_1_2.AssessmentResults) Model { +type result struct { + Uuid, Title string + Timestamp string + OscalResult *oscalTypes_1_1_2.Result + Findings *[]oscalTypes_1_1_2.Finding + Observations *[]oscalTypes_1_1_2.Observation + FindingsRows []table.Row + ObservationsRows []table.Row + FindingsMap map[string]table.Row + ObservationsMap map[string]table.Row + SummaryData summaryData +} + +type summaryData struct { + NumFindings, NumObservations int + NumFindingsSatisfied int + NumObservationsSatisfied int +} + +func GetResults(assessmentResults *oscalTypes_1_1_2.AssessmentResults) []result { results := make([]result, 0) - findings := make([]blist.Item, 0) - var selectedResult result if assessmentResults != nil { for _, r := range assessmentResults.Results { - results = append(results, result{ - uuid: r.UUID, - title: r.Title, - findings: r.Findings, - observations: r.Observations, - }) - } - } - if len(results) != 0 { - selectedResult = results[0] - observationMap := makeObservationMap(selectedResult.observations) - if selectedResult.findings != nil { - for _, f := range *selectedResult.findings { - // get the related observations - observations := make([]observation, 0) + numFindings := len(*r.Findings) + numObservations := len(*r.Observations) + numFindingsSatisfied := 0 + numObservationsSatisfied := 0 + findingsRows := make([]table.Row, 0) + observationsRows := make([]table.Row, 0) + observationsMap := make(map[string]table.Row) + findingsMap := make(map[string]table.Row) + observationsControlMap := make(map[string][]string, 0) + + for _, f := range *r.Findings { + findingString, err := common.ToYamlString(f) + if err != nil { + common.PrintToLog("error converting finding to yaml: %v", err) + findingString = "" + } + relatedObs := make([]string, 0) if f.RelatedObservations != nil { for _, o := range *f.RelatedObservations { - observationUuid := o.ObservationUuid - if _, ok := observationMap[observationUuid]; ok { - observations = append(observations, observationMap[observationUuid]) + relatedObs = append(relatedObs, o.ObservationUuid) + if _, ok := observationsControlMap[o.ObservationUuid]; !ok { + observationsControlMap[o.ObservationUuid] = []string{f.Target.TargetId} + } else { + observationsControlMap[o.ObservationUuid] = append(observationsControlMap[o.ObservationUuid], f.Target.TargetId) } } } - findings = append(findings, finding{ - title: f.Title, - uuid: f.UUID, - controlId: f.Target.TargetId, - state: f.Target.Status.State, - observations: observations, + if f.Target.Status.State == "satisfied" { + numFindingsSatisfied++ + } + + style, exists := satisfiedColors[f.Target.Status.State] + if !exists { + style = satisfiedColors["other"] + } + + findingRow := table.NewRow(table.RowData{ + ColumnKeyName: f.Target.TargetId, + ColumnKeyStatus: table.NewStyledCell(f.Target.Status.State, style), + ColumnKeyDescription: strings.ReplaceAll(f.Description, "\n", " "), + // Hidden columns + ColumnKeyFinding: findingString, + ColumnKeyRelatedObs: relatedObs, }) + findingsRows = append(findingsRows, findingRow) + findingsMap[f.Target.TargetId] = findingRow } - } - } - resultsPicker := viewport.New(pickerWidth, pickerHeight) - resultsPicker.Style = common.OverlayStyle - - f := blist.New(findings, common.NewUnfocusedDelegate(), width, height) - findingPicker := viewport.New(width, height) - findingPicker.Style = common.PanelStyle - - findingSummary := viewport.New(width, height) - findingSummary.Style = common.PanelStyle - observationSummary := viewport.New(width, height) - observationSummary.Style = common.PanelStyle - - help := common.NewHelpModel(false) - help.OneLine = true - help.ShortHelp = []key.Binding{assessmentHotkeys.Help} - - return Model{ - keys: assessmentHotkeys, - help: help, - results: results, - resultsPicker: resultsPicker, - selectedResult: selectedResult, - findings: f, - findingPicker: findingPicker, - findingSummary: findingSummary, - observationSummary: observationSummary, - } -} + for _, o := range *r.Observations { + state := "undefined" + var remarks strings.Builder + if o.RelevantEvidence != nil { + for _, e := range *o.RelevantEvidence { + if e.Description == "Result: satisfied\n" { + state = "satisfied" + } else if e.Description == "Result: not-satisfied\n" { + state = "not-satisfied" + } + if e.Remarks != "" { + remarks.WriteString(strings.ReplaceAll(e.Remarks, "\n", " ")) + } + } + if state == "satisfied" { + numObservationsSatisfied++ + } + } -func (m Model) Init() tea.Cmd { - return nil -} + style, exists := satisfiedColors[state] + if !exists { + style = satisfiedColors["other"] + } -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - var cmds []tea.Cmd + obsString, err := common.ToYamlString(o) + if err != nil { + common.PrintToLog("error converting observation to yaml: %v", err) + obsString = "" + } - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.UpdateSizing(msg.Height-common.TabOffset, msg.Width) + var controlIds []string + if ids, ok := observationsControlMap[o.UUID]; ok { + controlIds = ids + } - case tea.KeyMsg: - if m.open { - k := msg.String() - switch k { + obsRow := table.NewRow(table.RowData{ + ColumnKeyName: GetReadableObservationName(o.Description), + ColumnKeyStatus: table.NewStyledCell(state, style), + ColumnKeyControlIds: strings.Join(controlIds, ", "), + ColumnKeyDescription: remarks.String(), + // Hidden columns + ColumnKeyObservation: obsString, + ColumnKeyValidationId: findUuid(o.Description), + }) + observationsRows = append(observationsRows, obsRow) + observationsMap[o.UUID] = obsRow + } - case common.ContainsKey(k, m.keys.Quit.Keys()): - return m, tea.Quit + results = append(results, result{ + Uuid: r.UUID, + Title: r.Title, + OscalResult: &r, + Timestamp: r.Start.Format(time.RFC3339), + Findings: r.Findings, + Observations: r.Observations, + FindingsRows: findingsRows, + ObservationsRows: observationsRows, + FindingsMap: findingsMap, + ObservationsMap: observationsMap, + SummaryData: summaryData{ + NumFindings: numFindings, + NumObservations: numObservations, + NumFindingsSatisfied: numFindingsSatisfied, + NumObservationsSatisfied: numObservationsSatisfied, + }, + }) + } + } - case common.ContainsKey(k, m.keys.Help.Keys()): - m.help.ShowAll = !m.help.ShowAll + return results +} - case common.ContainsKey(k, m.keys.NavigateLeft.Keys()): - if m.focus == 0 { - m.focus = maxFocus +func GetResultComparison(selectedResult, comparedResult result) ([]table.Row, []table.Row) { + findingsRows := make([]table.Row, 0) + observationsRows := make([]table.Row, 0) + observations := make([]string, 0) + + if selectedResult.OscalResult != nil && comparedResult.OscalResult != nil { + resultComparison := pkgResult.NewResultComparisonMap(*selectedResult.OscalResult, *comparedResult.OscalResult) + for k, v := range resultComparison { + // Make compared finding row + var comparedFindingRow table.Row + var ok bool + if comparedFindingRow, ok = selectedResult.FindingsMap[k]; ok { + comparedFindingRow.Data[ColumnKeyStatusChange] = v.StateChange + if r, ok := comparedResult.FindingsMap[k]; ok { + // Finding exists in both results + comparedFindingRow.Data[ColumnKeyComparedFinding] = r.Data[ColumnKeyFinding] } else { - m.focus-- + // Finding is new + comparedFindingRow.Data[ColumnKeyComparedFinding] = "" } - m.updateKeyBindings() - - case common.ContainsKey(k, m.keys.NavigateRight.Keys()): - m.focus = (m.focus + 1) % (maxFocus + 1) - m.updateKeyBindings() - - case common.ContainsKey(k, m.keys.Up.Keys()): - if m.inResultOverlay && m.selectedResultIndex > 0 { - m.selectedResultIndex-- - m.resultsPicker.SetContent(m.updateViewportContent("view")) - } - - case common.ContainsKey(k, m.keys.Down.Keys()): - if m.inResultOverlay && m.selectedResultIndex < len(m.results)-1 { - m.selectedResultIndex++ - m.resultsPicker.SetContent(m.updateViewportContent("view")) + } else { + if comparedFindingRow, ok = comparedResult.FindingsMap[k]; ok { + // Finding was removed + comparedFindingRow.Data[ColumnKeyComparedFinding] = comparedFindingRow.Data[ColumnKeyFinding] + comparedFindingRow.Data[ColumnKeyFinding] = "" + comparedFindingRow.Data[ColumnKeyStatusChange] = v.StateChange } - - case common.ContainsKey(k, m.keys.Confirm.Keys()): - if m.focus == focusResultSelection { - if m.inResultOverlay { - if len(m.results) > 1 { - m.selectedResult = m.results[m.selectedResultIndex] + } + findingsRows = append(findingsRows, comparedFindingRow) + + // Make compared observation row + for _, op := range v.ObservationPairs { + if op != nil { + obsUuid := "" + var comparedObservationRow table.Row + if comparedObservationRow, ok = selectedResult.ObservationsMap[op.ObservationUuid]; ok { + obsUuid = op.ObservationUuid + comparedObservationRow.Data[ColumnKeyStatusChange] = op.StateChange + if r, ok := comparedResult.ObservationsMap[op.ComparedObservationUuid]; ok { + comparedObservationRow.Data[ColumnKeyComparedObservation] = r.Data[ColumnKeyObservation] + } else { + // Observation is new + comparedObservationRow.Data[ColumnKeyComparedObservation] = "" } - m.inResultOverlay = false } else { - m.inResultOverlay = true - m.resultsPicker.SetContent(m.updateViewportContent("view")) - } - } else if m.focus == focusCompareSelection { - if m.inResultOverlay { - if len(m.results) > 1 { - m.compareResult = m.results[m.selectedResultIndex] + if comparedObservationRow, ok = comparedResult.ObservationsMap[op.ComparedObservationUuid]; ok { + // Observation was removed + obsUuid = op.ComparedObservationUuid + comparedObservationRow.Data[ColumnKeyStatusChange] = op.StateChange + comparedObservationRow.Data[ColumnKeyComparedObservation] = comparedObservationRow.Data[ColumnKeyObservation] + comparedObservationRow.Data[ColumnKeyObservation] = "" } - m.inResultOverlay = false - } else { - m.inResultOverlay = true - m.resultsPicker.SetContent(m.updateViewportContent("compare")) } - } else if m.focus == focusFindings { - m.findingSummary.SetContent(m.renderSummary()) - } - - case common.ContainsKey(k, m.keys.Cancel.Keys()): - if m.inResultOverlay { - m.inResultOverlay = false + // Check if observation has already been added + if obsUuid != "" && !slices.Contains(observations, obsUuid) { + observations = append(observations, obsUuid) + observationsRows = append(observationsRows, comparedObservationRow) + } } } } } - m.findings, cmd = m.findings.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) + return findingsRows, observationsRows } -func (m Model) View() string { - if m.inResultOverlay { - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.resultsPicker.View(), lipgloss.WithWhitespaceChars(" ")) - } - return m.mainView() -} - -func (m Model) mainView() string { - // Add help panel at the top left - helpStyle := common.HelpStyle(m.width) - helpView := helpStyle.Render(m.help.View()) - - // Add viewport styles - focusedViewport := common.PanelStyle.BorderForeground(common.Focused) - focusedViewportHeaderColor := common.Focused - focusedDialogBox := common.DialogBoxStyle.BorderForeground(common.Focused) - - selectedResultDialogBox := common.DialogBoxStyle - compareResultDialogBox := common.DialogBoxStyle - findingsViewport := common.PanelStyle - findingsViewportHeader := common.Highlight - summaryViewport := common.PanelStyle - summaryViewportHeader := common.Highlight - observationsViewport := common.PanelStyle - observationsViewportHeader := common.Highlight - - switch m.focus { - case focusResultSelection: - selectedResultDialogBox = focusedDialogBox - case focusCompareSelection: - compareResultDialogBox = focusedDialogBox - case focusFindings: - findingsViewport = focusedViewport - findingsViewportHeader = focusedViewportHeaderColor - case focusSummary: - summaryViewport = focusedViewport - summaryViewportHeader = focusedViewportHeaderColor - case focusObservations: - observationsViewport = focusedViewport - observationsViewportHeader = focusedViewportHeaderColor - } - - // add panels at the top for selecting a result, selecting a comparison result - const dialogFixedWidth = 40 - - selectedResultLabel := common.LabelStyle.Render("Selected Result") - selectedResultText := common.TruncateText(getResultText(m.selectedResult), dialogFixedWidth) - selectedResultContent := selectedResultDialogBox.Width(dialogFixedWidth).Render(selectedResultText) - selectedResult := lipgloss.JoinHorizontal(lipgloss.Top, selectedResultLabel, selectedResultContent) - - compareResultLabel := common.LabelStyle.Render("Compare Result") - compareResultText := common.TruncateText(getResultText(m.compareResult), dialogFixedWidth) - compareResultContent := compareResultDialogBox.Width(dialogFixedWidth).Render(compareResultText) - compareResult := lipgloss.JoinHorizontal(lipgloss.Top, compareResultLabel, compareResultContent) - - resultSelectionContent := lipgloss.JoinHorizontal(lipgloss.Top, selectedResult, compareResult) - - // Add Controls panel + Results Tables - m.findings.SetShowTitle(false) - - m.findingPicker.Style = findingsViewport - m.findingPicker.SetContent(m.findings.View()) - bottomLeftView := fmt.Sprintf("%s\n%s", common.HeaderView("Findings List", m.findingPicker.Width-common.PanelStyle.GetMarginRight(), findingsViewportHeader), m.findingPicker.View()) - - m.findingSummary.Style = summaryViewport - m.findingSummary.SetContent(m.renderSummary()) - summaryPanel := fmt.Sprintf("%s\n%s", common.HeaderView("Summary", m.findingSummary.Width-common.PanelStyle.GetPaddingRight(), summaryViewportHeader), m.findingSummary.View()) - - m.observationSummary.Style = observationsViewport - m.observationSummary.SetContent(m.renderObservations()) - observationsPanel := fmt.Sprintf("%s\n%s", common.HeaderView("Observations", m.observationSummary.Width-common.PanelStyle.GetPaddingRight(), observationsViewportHeader), m.observationSummary.View()) - - bottomRightView := lipgloss.JoinVertical(lipgloss.Top, summaryPanel, observationsPanel) - bottomContent := lipgloss.JoinHorizontal(lipgloss.Top, bottomLeftView, bottomRightView) - - return lipgloss.JoinVertical(lipgloss.Top, helpView, resultSelectionContent, bottomContent) -} - -func (m Model) updateViewportContent(resultType string) string { - // TODO: refactor this to use the PiickerModel - help := common.NewHelpModel(true) - help.ShortHelp = common.ShortHelpPicker - s := strings.Builder{} - s.WriteString(fmt.Sprintf("Select a result to %s:\n\n", resultType)) - - for i, result := range m.results { - if m.selectedResultIndex == i { - s.WriteString("(•) ") - } else { - s.WriteString("( ) ") +func getComparedResults(results []result, selectedResult result) []string { + comparedResults := []string{"None"} + for _, r := range results { + if r.Uuid != selectedResult.Uuid { + comparedResults = append(comparedResults, getResultText(r)) } - s.WriteString(getResultText(result)) - s.WriteString("\n") } - - return lipgloss.JoinVertical(lipgloss.Top, s.String(), help.View()) -} - -func (m Model) renderSummary() string { - return "⚠️ Summary Under Construction ⚠️" -} - -func (m Model) renderObservations() string { - return "⚠️ Observations Under Construction ⚠️" + return comparedResults } func getResultText(result result) string { - if result.uuid == "" { + var resultText strings.Builder + if result.Uuid == "" { return "No Result Selected" } - return fmt.Sprintf("%s - %s", result.title, result.uuid) -} - -func makeObservationMap(observations *[]oscalTypes_1_1_2.Observation) map[string]observation { - observationMap := make(map[string]observation) - - for _, o := range *observations { - validationId := findUuid(o.Description) - state := "not-satisfied" - remarks := strings.Builder{} - if o.RelevantEvidence != nil { - for _, re := range *o.RelevantEvidence { - if re.Description == "Result: satisfied\n" { - state = "satisfied" - } else if re.Description == "Result: not-satisfied\n" { - state = "not-satisfied" - } - remarks.WriteString(re.Remarks) - } + resultText.WriteString(result.Title) + if result.OscalResult != nil { + thresholdFound, threshold := oscal.GetProp("threshold", oscal.LULA_NAMESPACE, result.OscalResult.Props) + if thresholdFound && threshold == "true" { + resultText.WriteString(", Threshold") } - observationMap[o.UUID] = observation{ - uuid: o.UUID, - description: o.Description, - remarks: remarks.String(), - state: state, - validationId: validationId, + targetFound, target := oscal.GetProp("target", oscal.LULA_NAMESPACE, result.OscalResult.Props) + if targetFound { + resultText.WriteString(fmt.Sprintf(", %s", target)) } } - return observationMap + resultText.WriteString(fmt.Sprintf(" - %s", result.Timestamp)) + + return resultText.String() } func findUuid(input string) string { @@ -325,3 +271,22 @@ func findUuid(input string) string { return re.FindString(input) } + +func GetReadableObservationName(desc string) string { + // Define the regular expression pattern + pattern := `\[TEST\]: ([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}) - (.+)` + + // Compile the regular expression + re := regexp.MustCompile(pattern) + + // Find the matches + matches := re.FindStringSubmatch(desc) + + if len(matches) == 3 { + message := matches[2] + + return message + } else { + return desc + } +} diff --git a/src/internal/tui/assessment_results/assessment-results_test.go b/src/internal/tui/assessment_results/assessment-results_test.go index 5dc56898..e60987f4 100644 --- a/src/internal/tui/assessment_results/assessment-results_test.go +++ b/src/internal/tui/assessment_results/assessment-results_test.go @@ -2,39 +2,166 @@ package assessmentresults_test import ( "testing" - "time" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/defenseunicorns/lula/src/internal/testhelpers" assessmentresults "github.com/defenseunicorns/lula/src/internal/tui/assessment_results" - "github.com/defenseunicorns/lula/src/internal/tui/common" - "github.com/muesli/termenv" + "github.com/defenseunicorns/lula/src/pkg/common/result" ) -const ( - timeout = time.Second * 20 - maxRetries = 3 - height = common.DefaultHeight - width = common.DefaultWidth +func TestGetResults(t *testing.T) { + oscalModel := testhelpers.OscalFromPath(t, validAssessmentResultsMulti) + results := assessmentresults.GetResults(oscalModel.AssessmentResults) - validAssessmentResults = "../../../test/unit/common/oscal/valid-assessment-results.yaml" -) + require.Equal(t, 2, len(results)) + + // Check summary data about each result - should be sorted deterministically by time (latest first) + assert.Equal(t, 2, results[0].SummaryData.NumFindings) + assert.Equal(t, 0, results[0].SummaryData.NumFindingsSatisfied) + assert.Equal(t, 2, results[0].SummaryData.NumObservations) + assert.Equal(t, 0, results[0].SummaryData.NumObservationsSatisfied) -func init() { - lipgloss.SetColorProfile(termenv.Ascii) + assert.Equal(t, 2, results[1].SummaryData.NumFindings) + assert.Equal(t, 2, results[1].SummaryData.NumFindingsSatisfied) + assert.Equal(t, 2, results[1].SummaryData.NumObservations) + assert.Equal(t, 2, results[1].SummaryData.NumObservationsSatisfied) } -// TestAssessmentResultsBasicView tests that the model is created correctly from an assessment results model -func TestAssessmentResultsBasicView(t *testing.T) { - oscalModel := testhelpers.OscalFromPath(t, validAssessmentResults) - model := assessmentresults.NewAssessmentResultsModel(oscalModel.AssessmentResults) - model.Open(height, width) +func TestGetResultsComparison(t *testing.T) { + + t.Run("Simple Satisfied -> Not-Satisfied", func(t *testing.T) { + oscalModel := testhelpers.OscalFromPath(t, validAssessmentResultsMulti) + results := assessmentresults.GetResults(oscalModel.AssessmentResults) + require.Equal(t, 2, len(results)) + + // Not-Satisfied is new; Compared to Satisfied + findingsRows, observationsRows := assessmentresults.GetResultComparison(results[0], results[1]) + require.Equal(t, 2, len(findingsRows)) + require.Equal(t, 2, len(findingsRows)) + for _, row := range findingsRows { + if row.Data[assessmentresults.ColumnKeyName] == "ID-1" { + assert.Equal(t, result.SATISFIED_TO_NOT_SATISFIED, row.Data[assessmentresults.ColumnKeyStatusChange]) + } else { + assert.Equal(t, result.SATISFIED_TO_NOT_SATISFIED, row.Data[assessmentresults.ColumnKeyStatusChange]) // Should be ID-2 + } + } + + require.Equal(t, 2, len(observationsRows)) + for _, row := range observationsRows { + if row.Data[assessmentresults.ColumnKeyControlIds] == "ID-1" { + assert.Equal(t, result.SATISFIED_TO_NOT_SATISFIED, row.Data[assessmentresults.ColumnKeyStatusChange]) + } else { + assert.Equal(t, result.SATISFIED_TO_NOT_SATISFIED, row.Data[assessmentresults.ColumnKeyStatusChange]) // Linked to ID-2 + } + } + }) + + t.Run("Removed Finding", func(t *testing.T) { + oscalModel := testhelpers.OscalFromPath(t, validAssessmentResultsRemovedFinding) + results := assessmentresults.GetResults(oscalModel.AssessmentResults) + require.Equal(t, 2, len(results)) + + // Finding is removed, check both rows have the right status change + findingsRows, observationsRows := assessmentresults.GetResultComparison(results[0], results[1]) + require.Equal(t, 2, len(findingsRows)) + for _, row := range findingsRows { + if row.Data[assessmentresults.ColumnKeyName] == "ID-1" { + assert.Equal(t, result.SATISFIED_TO_NOT_SATISFIED, row.Data[assessmentresults.ColumnKeyStatusChange]) + } else { + assert.Equal(t, result.REMOVED, row.Data[assessmentresults.ColumnKeyStatusChange]) // Should be ID-2, removed + } + } + + // Observation is removed, check both rows have the right status change + require.Equal(t, 2, len(observationsRows)) + for _, row := range observationsRows { + if row.Data[assessmentresults.ColumnKeyControlIds] == "ID-1" { + assert.Equal(t, result.SATISFIED_TO_NOT_SATISFIED, row.Data[assessmentresults.ColumnKeyStatusChange]) + } else { + assert.Equal(t, result.REMOVED, row.Data[assessmentresults.ColumnKeyStatusChange]) // Should be linked to ID-2, removed + } + } + }) + + t.Run("Added Finding", func(t *testing.T) { + oscalModel := testhelpers.OscalFromPath(t, validAssessmentResultsAddedFinding) + results := assessmentresults.GetResults(oscalModel.AssessmentResults) + require.Equal(t, 2, len(results)) - msgs := []tea.Msg{} + // Finding is added, check both rows have the right status change + findingsRows, observationsRows := assessmentresults.GetResultComparison(results[0], results[1]) + require.Equal(t, 2, len(findingsRows)) + for _, row := range findingsRows { + if row.Data[assessmentresults.ColumnKeyName] == "ID-1" { + assert.Equal(t, result.SATISFIED_TO_NOT_SATISFIED, row.Data[assessmentresults.ColumnKeyStatusChange]) + } else { + assert.Equal(t, result.NEW, row.Data[assessmentresults.ColumnKeyStatusChange]) // Should be ID-2, added + } + } + + require.Equal(t, 2, len(observationsRows)) + for _, row := range observationsRows { + if row.Data[assessmentresults.ColumnKeyControlIds] == "ID-1" { + assert.Equal(t, result.SATISFIED_TO_NOT_SATISFIED, row.Data[assessmentresults.ColumnKeyStatusChange]) + } else { + assert.Equal(t, result.NEW, row.Data[assessmentresults.ColumnKeyStatusChange]) // Should be linked to ID-2, added + } + } + }) + + t.Run("Removed Observation", func(t *testing.T) { + oscalModel := testhelpers.OscalFromPath(t, validAssessmentResultsRemovedObs) + results := assessmentresults.GetResults(oscalModel.AssessmentResults) + require.Equal(t, 2, len(results)) + + // Finding is removed, check both rows have the right status change + findingsRows, observationsRows := assessmentresults.GetResultComparison(results[0], results[1]) + require.Equal(t, 1, len(findingsRows)) + assert.Equal(t, result.SATISFIED_TO_NOT_SATISFIED, findingsRows[0].Data[assessmentresults.ColumnKeyStatusChange]) // Should be ID-1 + + require.Equal(t, 2, len(observationsRows)) + for _, row := range observationsRows { + if row.Data[assessmentresults.ColumnKeyValidationId] == "88AB3470-B96B-4D7C-BC36-02BF9563C46C" { + assert.Equal(t, result.SATISFIED_TO_NOT_SATISFIED, row.Data[assessmentresults.ColumnKeyStatusChange]) + } else { + assert.Equal(t, result.REMOVED, row.Data[assessmentresults.ColumnKeyStatusChange]) // Should be linked to ID-1, removed + } + } + }) + +} + +func TestGetReadableDesc(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + desc string + expected string + }{ + { + name: "Test get readable desc", + desc: "[TEST]: 67456ae8-4505-4c93-b341-d977d90cb125 - istio-health-check", + expected: "istio-health-check", + }, + { + name: "Test get readable desc - no uuid", + desc: "test description", + expected: "test description", + }, + { + name: "Test get readable desc - no description", + desc: "[TEST]: 12345678-1234-1234-1234-123456789012", + expected: "[TEST]: 12345678-1234-1234-1234-123456789012", + }, + } - err := testhelpers.RunTestModelView(t, model, nil, msgs, timeout, maxRetries, height, width) - if err != nil { - t.Fatal(err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := assessmentresults.GetReadableObservationName(tt.desc) + assert.Equal(t, tt.expected, got) + }) } } diff --git a/src/internal/tui/assessment_results/focus.go b/src/internal/tui/assessment_results/focus.go new file mode 100644 index 00000000..857d5faf --- /dev/null +++ b/src/internal/tui/assessment_results/focus.go @@ -0,0 +1,79 @@ +package assessmentresults + +import ( + "github.com/defenseunicorns/lula/src/internal/tui/common" +) + +type focus int + +const ( + noFocus focus = iota + focusResultSelection + focusCompareSelection + focusFindings + focusObservations +) + +var maxFocus = focusObservations + +func (m *Model) updateKeyBindings() { + m.outOfFocus() + m.updateFocusHelpKeys() + + switch m.focus { + case focusFindings: + m.findingsTable = m.findingsTable.WithKeyMap(common.FocusedTableKeyMap()) + m.findingsTable = m.findingsTable.Focused(true) + case focusObservations: + m.observationsTable = m.observationsTable.WithKeyMap(common.FocusedTableKeyMap()) + m.observationsTable = m.observationsTable.Focused(true) + } +} + +func (m *Model) outOfFocus() { + focusMinusOne := m.focus - 1 + focusPlusOne := m.focus + 1 + + if m.focus == 0 { + focusMinusOne = maxFocus + } + if m.focus == maxFocus { + focusPlusOne = 0 + } + + for _, f := range []focus{focusMinusOne, focusPlusOne} { + switch f { + case focusFindings: + m.findingsTable = m.findingsTable.WithKeyMap(common.UnfocusedTableKeyMap()) + m.findingsTable = m.findingsTable.Focused(false) + case focusObservations: + m.observationsTable = m.observationsTable.WithKeyMap(common.UnfocusedTableKeyMap()) + m.observationsTable = m.observationsTable.Focused(false) + } + } +} + +func (m *Model) updateFocusHelpKeys() { + switch m.focus { + case focusResultSelection: + m.help.ShortHelp = shortHelpDialogBox + m.help.FullHelpOneLine = fullHelpDialogBoxOneLine + m.help.FullHelp = fullHelpDialogBox + case focusCompareSelection: + m.help.ShortHelp = shortHelpDialogBox + m.help.FullHelpOneLine = fullHelpDialogBoxOneLine + m.help.FullHelp = fullHelpDialogBox + case focusFindings: + m.help.ShortHelp = common.ShortHelpTableWithSelect + m.help.FullHelpOneLine = common.FullHelpTableWithSelectOneLine + m.help.FullHelp = common.FullHelpTableWithSelect + case focusObservations: + m.help.ShortHelp = common.ShortHelpTable + m.help.FullHelpOneLine = common.FullHelpTableOneLine + m.help.FullHelp = common.FullHelpTable + default: + m.help.ShortHelp = shortHelpNoFocus + m.help.FullHelpOneLine = fullHelpNoFocusOneLine + m.help.FullHelp = fullHelpNoFocus + } +} diff --git a/src/internal/tui/assessment_results/keys.go b/src/internal/tui/assessment_results/keys.go index e5725889..4859b466 100644 --- a/src/internal/tui/assessment_results/keys.go +++ b/src/internal/tui/assessment_results/keys.go @@ -9,55 +9,57 @@ type keys struct { Validate key.Binding Evaluate key.Binding Confirm key.Binding + Select key.Binding Cancel key.Binding Navigation key.Binding NavigateLeft key.Binding NavigateRight key.Binding SwitchModels key.Binding - Up key.Binding - Down key.Binding + Detail key.Binding + Filter key.Binding Help key.Binding Quit key.Binding } -var assessmentHotkeys = keys{ - Quit: common.CommonKeys.Quit, - Help: common.CommonKeys.Help, - Validate: key.NewBinding( - key.WithKeys("v"), - key.WithHelp("v", "validate"), - ), - Evaluate: key.NewBinding( - key.WithKeys("e"), - key.WithHelp("e", "evaluate"), - ), +var assessmentKeys = keys{ + Quit: common.CommonKeys.Quit, + Help: common.CommonKeys.Help, Confirm: common.CommonKeys.Confirm, + Select: common.CommonKeys.Select, Cancel: common.CommonKeys.Cancel, Navigation: common.CommonKeys.Navigation, NavigateLeft: common.CommonKeys.NavigateLeft, NavigateRight: common.CommonKeys.NavigateRight, SwitchModels: common.CommonKeys.NavigateModels, - Up: common.PickerKeys.Up, - Down: common.PickerKeys.Down, + Detail: common.CommonKeys.Detail, + Filter: common.TableKeys.Filter, } -func (k keys) ShortHelp() []key.Binding { - return []key.Binding{k.Validate, k.Evaluate, k.Help} +var assessmentKeysInFilter = keys{ + Confirm: common.CommonKeys.Confirm, + Cancel: common.CommonKeys.Cancel, } -func (k keys) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.Evaluate}, {k.Confirm}, {k.Navigation}, {k.SwitchModels}, {k.Help}, {k.Quit}, +var ( + // No focus + shortHelpNoFocus = []key.Binding{ + assessmentKeys.Navigation, assessmentKeys.SwitchModels, assessmentKeys.Help, + } + fullHelpNoFocusOneLine = []key.Binding{ + assessmentKeys.Navigation, assessmentKeys.SwitchModels, assessmentKeys.Help, + } + fullHelpNoFocus = [][]key.Binding{ + {assessmentKeys.Navigation}, {assessmentKeys.SwitchModels}, {assessmentKeys.Help}, } -} - -func (m *Model) updateKeyBindings() { - m.findings.KeyMap = common.UnfocusedListKeyMap() - m.findings.SetDelegate(common.NewUnfocusedDelegate()) - switch m.focus { - case focusFindings: - m.findings.KeyMap = common.FocusedListKeyMap() - m.findings.SetDelegate(common.NewFocusedDelegate()) + // focus dialog box + shortHelpDialogBox = []key.Binding{ + assessmentKeys.Select, assessmentKeys.Navigation, assessmentKeys.SwitchModels, assessmentKeys.Help, } -} + fullHelpDialogBoxOneLine = []key.Binding{ + assessmentKeys.Select, assessmentKeys.Navigation, assessmentKeys.SwitchModels, assessmentKeys.Help, + } + fullHelpDialogBox = [][]key.Binding{ + {assessmentKeys.Select}, {assessmentKeys.Navigation}, {assessmentKeys.SwitchModels}, {assessmentKeys.Help}, + } +) diff --git a/src/internal/tui/assessment_results/model.go b/src/internal/tui/assessment_results/model.go new file mode 100644 index 00000000..50c74612 --- /dev/null +++ b/src/internal/tui/assessment_results/model.go @@ -0,0 +1,546 @@ +package assessmentresults + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" + "github.com/defenseunicorns/lula/src/internal/tui/common" + "github.com/evertras/bubble-table/table" +) + +const ( + height = 20 + width = 12 + pickerHeight = 20 + pickerWidth = 80 + dialogFixedWidth = 40 +) + +const ( + resultPicker common.PickerKind = "result" + comparedResultPicker common.PickerKind = "compared result" + ColumnKeyName = "name" + ColumnKeyStatus = "status" + ColumnKeyDescription = "description" + ColumnKeyStatusChange = "status_change" + ColumnKeyFinding = "finding" + ColumnKeyRelatedObs = "related_obs" + ColumnKeyControlIds = "control_ids" + ColumnKeyComparedFinding = "compared_finding" + ColumnKeyObservation = "observation" + ColumnKeyComparedObservation = "compared_observation" + ColumnKeyValidationId = "validation_id" +) + +type Model struct { + open bool + help common.HelpModel + keys keys + focus focus + results []result + resultsPicker common.PickerModel + selectedResult result + selectedResultIndex int + comparedResultsPicker common.PickerModel + comparedResult result + findingsSummary viewport.Model + findingsTable table.Model + observationsSummary viewport.Model + observationsTable table.Model + currentObservations []table.Row + detailView common.DetailModel + width int + height int +} + +type ModelOpenMsg struct { + Height int + Width int +} +type ModelCloseMsg struct{} + +func NewAssessmentResultsModel(assessmentResults *oscalTypes_1_1_2.AssessmentResults) Model { + help := common.NewHelpModel(false) + help.OneLine = true + help.ShortHelp = shortHelpNoFocus + + resultsPicker := common.NewPickerModel("Select a Result", resultPicker, []string{}, 0) + comparedResultsPicker := common.NewPickerModel("Select a Result to Compare", comparedResultPicker, []string{}, 0) + + findingsSummary := viewport.New(width, height) + findingsSummary.Style = common.PanelStyle + observationsSummary := viewport.New(width, height) + observationsSummary.Style = common.PanelStyle + + model := Model{ + keys: assessmentKeys, + help: help, + resultsPicker: resultsPicker, + comparedResultsPicker: comparedResultsPicker, + findingsSummary: findingsSummary, + observationsSummary: observationsSummary, + detailView: common.NewDetailModel(), + } + + model.UpdateWithAssessmentResults(assessmentResults) + + return model +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case ModelOpenMsg: + m.Open(msg.Height, msg.Width) + + case tea.WindowSizeMsg: + m.updateSizing(msg.Height-common.TabOffset, msg.Width) + + case tea.KeyMsg: + + common.DumpToLog(msg) + k := msg.String() + switch k { + case common.ContainsKey(k, m.keys.Help.Keys()): + m.help.ShowAll = !m.help.ShowAll + + case common.ContainsKey(k, m.keys.NavigateLeft.Keys()): + if !m.inOverlay() { + if m.focus == 0 { + m.focus = maxFocus + } else { + m.focus-- + } + m.updateKeyBindings() + } + + case common.ContainsKey(k, m.keys.NavigateRight.Keys()): + if !m.inOverlay() { + m.focus = (m.focus + 1) % (maxFocus + 1) + m.updateKeyBindings() + } + + case common.ContainsKey(k, m.keys.Confirm.Keys()): + m.keys = assessmentKeys + switch m.focus { + case focusResultSelection: + if len(m.results) > 0 && !m.resultsPicker.Open { + return m, func() tea.Msg { + return common.PickerOpenMsg{ + Kind: resultPicker, + } + } + } + + case focusCompareSelection: + if len(m.results) > 0 && !m.comparedResultsPicker.Open { + // TODO: get compared result items to send with picker open + return m, func() tea.Msg { + return common.PickerOpenMsg{ + Kind: comparedResultPicker, + } + } + } + + case focusFindings: + // Select the observations + if !m.detailView.Open && m.findingsTable.HighlightedRow().Data != nil { + m.observationsTable = m.observationsTable.WithRows(m.getObservationsByFinding(m.findingsTable.HighlightedRow().Data[ColumnKeyRelatedObs].([]string))) + } + } + + case common.ContainsKey(k, m.keys.Cancel.Keys()): + m.keys = assessmentKeys + switch m.focus { + case focusFindings: + m.observationsTable = m.observationsTable.WithRows(m.currentObservations) + } + m.updateKeyBindings() + + case common.ContainsKey(k, m.keys.Detail.Keys()): + switch m.focus { + case focusFindings: + if m.findingsTable.HighlightedRow().Data != nil { + m.findingsTable = m.findingsTable.WithKeyMap(common.UnfocusedTableKeyMap()) + return m, func() tea.Msg { + return common.DetailOpenMsg{ + Content: m.getFindingsDetail(), + WindowHeight: (m.height + common.TabOffset), + WindowWidth: m.width, + } + } + } + + case focusObservations: + if m.observationsTable.HighlightedRow().Data != nil { + m.observationsTable = m.observationsTable.WithKeyMap(common.UnfocusedTableKeyMap()) + return m, func() tea.Msg { + return common.DetailOpenMsg{ + Content: m.getObsDetail(), + WindowHeight: (m.height + common.TabOffset), + WindowWidth: m.width, + } + } + } + } + + case common.ContainsKey(k, m.keys.Filter.Keys()): + // Lock keys during table filter + if m.focus == focusFindings && !m.detailView.Open { + m.keys = assessmentKeysInFilter + } + if m.focus == focusObservations && !m.detailView.Open { + m.keys = assessmentKeysInFilter + } + } + + case common.PickerItemSelected: + if msg.From == resultPicker { + m.selectedResultIndex = msg.Selected + m.selectedResult = m.results[m.selectedResultIndex] + m.findingsTable, m.observationsTable = m.getSingleResultTables(m.selectedResult.FindingsRows, m.selectedResult.ObservationsRows) + m.currentObservations = m.selectedResult.ObservationsRows + // Update comparison + m.comparedResult = result{} + m.comparedResultsPicker.UpdateItems(getComparedResults(m.results, m.selectedResult)) + } else if msg.From == comparedResultPicker { + // First item will always be "None", so return single table if selected + if msg.Selected == 0 { + if m.comparedResult.OscalResult != nil { + m.findingsTable, m.observationsTable = m.getSingleResultTables(m.selectedResult.FindingsRows, m.selectedResult.ObservationsRows) + m.currentObservations = m.selectedResult.ObservationsRows + m.comparedResult = result{} + } + } else { + if m.selectedResultIndex < msg.Selected { + m.comparedResult = m.results[msg.Selected] + } else { + m.comparedResult = m.results[msg.Selected-1] + } + m.findingsTable, m.observationsTable, m.currentObservations = m.getComparedResultTables(m.selectedResult, m.comparedResult) + } + } + } + + mdl, cmd := m.resultsPicker.Update(msg) + m.resultsPicker = mdl.(common.PickerModel) + cmds = append(cmds, cmd) + + mdl, cmd = m.comparedResultsPicker.Update(msg) + m.comparedResultsPicker = mdl.(common.PickerModel) + cmds = append(cmds, cmd) + + mdl, cmd = m.detailView.Update(msg) + m.detailView = mdl.(common.DetailModel) + cmds = append(cmds, cmd) + + m.findingsTable, cmd = m.findingsTable.Update(msg) + cmds = append(cmds, cmd) + + m.observationsTable, cmd = m.observationsTable.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + if m.resultsPicker.Open { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.resultsPicker.View(), lipgloss.WithWhitespaceChars(" ")) + } + if m.comparedResultsPicker.Open { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.comparedResultsPicker.View(), lipgloss.WithWhitespaceChars(" ")) + } + if m.detailView.Open { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.detailView.View(), lipgloss.WithWhitespaceChars(" ")) + } + + return m.mainView() +} + +func (m Model) mainView() string { + // Add help panel at the top left + helpStyle := common.HelpStyle(m.width) + helpView := helpStyle.Render(m.help.View()) + + // Add viewport styles + focusedViewport := common.PanelStyle.BorderForeground(common.Focused) + focusedViewportHeaderColor := common.Focused + focusedDialogBox := common.DialogBoxStyle.BorderForeground(common.Focused) + + selectedResultDialogBox := common.DialogBoxStyle + comparedResultDialogBox := common.DialogBoxStyle + findingsViewport := common.PanelStyle + findingsViewportHeader := common.Highlight + findingsTableStyle := common.TableStyleBase + observationsViewport := common.PanelStyle + observationsViewportHeader := common.Highlight + observationsTableStyle := common.TableStyleBase + + switch m.focus { + case focusResultSelection: + selectedResultDialogBox = focusedDialogBox + case focusCompareSelection: + comparedResultDialogBox = focusedDialogBox + case focusFindings: + findingsViewport = focusedViewport + findingsViewportHeader = focusedViewportHeaderColor + findingsTableStyle = common.TableStyleActive + case focusObservations: + observationsViewport = focusedViewport + observationsViewportHeader = focusedViewportHeaderColor + observationsTableStyle = common.TableStyleActive + } + + // add panels at the top for selecting a result, selecting a comparison result + const dialogFixedWidth = 40 + + selectedResultLabel := common.LabelStyle.Render("Selected Result") + selectedResultText := common.TruncateText(getResultText(m.selectedResult), dialogFixedWidth) + selectedResultContent := selectedResultDialogBox.Width(dialogFixedWidth).Render(selectedResultText) + selectedResult := lipgloss.JoinHorizontal(lipgloss.Top, selectedResultLabel, selectedResultContent) + + comparedResultLabel := common.LabelStyle.Render("Compare Result") + comparedResultText := common.TruncateText(getResultText(m.comparedResult), dialogFixedWidth) + comparedResultContent := comparedResultDialogBox.Width(dialogFixedWidth).Render(comparedResultText) + comparedResult := lipgloss.JoinHorizontal(lipgloss.Top, comparedResultLabel, comparedResultContent) + + resultSelectionContent := lipgloss.JoinHorizontal(lipgloss.Top, selectedResult, comparedResult) + + // Write summary + findingsSatisfied := lipgloss.NewStyle().Foreground(lipgloss.Color("#3ad33c")).Render(fmt.Sprintf("%d", m.selectedResult.SummaryData.NumFindingsSatisfied)) + findingsNotSatisfied := lipgloss.NewStyle().Foreground(lipgloss.Color("#e36750")).Render(fmt.Sprintf("%d", m.selectedResult.SummaryData.NumFindings-m.selectedResult.SummaryData.NumFindingsSatisfied)) + observationsSatisfied := lipgloss.NewStyle().Foreground(lipgloss.Color("#3ad33c")).Render(fmt.Sprintf("%d", m.selectedResult.SummaryData.NumObservationsSatisfied)) + observationsNotSatisfied := lipgloss.NewStyle().Foreground(lipgloss.Color("#e36750")).Render(fmt.Sprintf("%d", m.selectedResult.SummaryData.NumObservations-m.selectedResult.SummaryData.NumObservationsSatisfied)) + summaryText := fmt.Sprintf("Summary: %d (%s/%s) Findings - %d (%s/%s) Observations", + m.selectedResult.SummaryData.NumFindings, findingsSatisfied, findingsNotSatisfied, + m.selectedResult.SummaryData.NumObservations, observationsSatisfied, observationsNotSatisfied, + ) + + // Write compared summary + if m.comparedResult.OscalResult != nil { + comparedFindingsSatisfied := lipgloss.NewStyle().Foreground(lipgloss.Color("#3ad33c")).Render(fmt.Sprintf("%d", m.comparedResult.SummaryData.NumFindingsSatisfied)) + comparedFindingsNotSatisfied := lipgloss.NewStyle().Foreground(lipgloss.Color("#e36750")).Render(fmt.Sprintf("%d", m.comparedResult.SummaryData.NumFindings-m.comparedResult.SummaryData.NumFindingsSatisfied)) + comparedObservationsSatisfied := lipgloss.NewStyle().Foreground(lipgloss.Color("#3ad33c")).Render(fmt.Sprintf("%d", m.comparedResult.SummaryData.NumObservationsSatisfied)) + comparedObservationsNotSatisfied := lipgloss.NewStyle().Foreground(lipgloss.Color("#e36750")).Render(fmt.Sprintf("%d", m.comparedResult.SummaryData.NumObservations-m.comparedResult.SummaryData.NumObservationsSatisfied)) + summaryText += fmt.Sprintf(" | Compared Summary: %d (%s/%s) Findings - %d (%s/%s) Observations", + m.comparedResult.SummaryData.NumFindings, comparedFindingsSatisfied, comparedFindingsNotSatisfied, + m.comparedResult.SummaryData.NumObservations, comparedObservationsSatisfied, comparedObservationsNotSatisfied, + ) + } + + summary := lipgloss.JoinHorizontal(lipgloss.Top, common.SummaryTextStyle.Render(summaryText)) + + // Add Tables + m.findingsSummary.Style = findingsViewport + m.findingsTable = m.findingsTable.WithBaseStyle(findingsTableStyle) + m.findingsSummary.SetContent(m.findingsTable.View()) + findingsPanel := fmt.Sprintf("%s\n%s", common.HeaderView("Findings", m.findingsSummary.Width-common.PanelStyle.GetPaddingRight(), findingsViewportHeader), m.findingsSummary.View()) + + m.observationsSummary.Style = observationsViewport + m.observationsTable = m.observationsTable.WithBaseStyle(observationsTableStyle) + m.observationsSummary.SetContent(m.observationsTable.View()) + observationsPanel := fmt.Sprintf("%s\n%s", common.HeaderView("Observations", m.observationsSummary.Width-common.PanelStyle.GetPaddingRight(), observationsViewportHeader), m.observationsSummary.View()) + + bottomContent := lipgloss.JoinVertical(lipgloss.Top, summary, findingsPanel, observationsPanel) + + return lipgloss.JoinVertical(lipgloss.Top, helpView, resultSelectionContent, bottomContent) +} + +func (m *Model) Close() { + m.open = false +} + +func (m *Model) Open(height, width int) { + m.open = true + m.updateSizing(height, width) +} + +func (m *Model) GetDimensions() (height, width int) { + return m.height, m.width +} + +func (m *Model) UpdateWithAssessmentResults(assessmentResults *oscalTypes_1_1_2.AssessmentResults) { + var selectedResult result + + results := GetResults(assessmentResults) + + if len(results) != 0 { + selectedResult = results[0] + } + + // Update model parameters + resultItems := make([]string, len(results)) + for i, c := range results { + resultItems[i] = getResultText(c) + } + + m.results = results + m.selectedResult = selectedResult + m.resultsPicker.UpdateItems(resultItems) + m.comparedResultsPicker.UpdateItems(getComparedResults(results, selectedResult)) + + m.findingsTable, m.observationsTable = m.getSingleResultTables(selectedResult.FindingsRows, selectedResult.ObservationsRows) + m.currentObservations = selectedResult.ObservationsRows +} + +func (m *Model) updateSizing(height, width int) { + m.height = height + m.width = width + totalHeight := m.height + + topSectionHeight := common.HelpStyle(m.width).GetHeight() + common.DialogBoxStyle.GetHeight() + bottomSectionHeight := totalHeight - topSectionHeight - 2 // 2 for summary height + bottomPanelHeight := (bottomSectionHeight - 2*common.PanelTitleStyle.GetHeight() - 2*common.PanelTitleStyle.GetVerticalMargins()) / 2 + panelWidth := width - 4 + panelInternalWidth := panelWidth - common.PanelStyle.GetHorizontalPadding() - common.PanelStyle.GetHorizontalMargins() - 2 + + // Update widget dimensions + m.findingsSummary.Height = bottomPanelHeight + m.findingsSummary.Width = panelWidth + findingsRowHeight := bottomPanelHeight - common.PanelTitleStyle.GetHeight() - common.PanelStyle.GetVerticalPadding() - 6 + m.findingsTable = m.findingsTable.WithTargetWidth(panelInternalWidth).WithPageSize(findingsRowHeight) + m.observationsSummary.Height = bottomPanelHeight + m.observationsSummary.Width = panelWidth + observationsRowHeight := bottomPanelHeight - common.PanelTitleStyle.GetHeight() - common.PanelStyle.GetVerticalPadding() - 6 + m.observationsTable = m.observationsTable.WithTargetWidth(panelInternalWidth).WithPageSize(observationsRowHeight) +} + +func (m *Model) inOverlay() bool { + return m.resultsPicker.Open || m.comparedResultsPicker.Open || m.detailView.Open +} + +func (m *Model) getObservationsByFinding(relatedObs []string) []table.Row { + obsRows := make([]table.Row, 0) + for _, uuid := range relatedObs { + if obsRow, ok := m.selectedResult.ObservationsMap[uuid]; ok { + obsRows = append(obsRows, obsRow) + } + } + + return obsRows +} + +func (m *Model) getSingleResultTables(findingsRows, observationsRows []table.Row) (findingsTable table.Model, observationsTable table.Model) { + findingsTableColumns := []table.Column{ + table.NewFlexColumn(ColumnKeyName, "Control", 1).WithFiltered(true), + table.NewFlexColumn(ColumnKeyStatus, "Status", 1), + table.NewFlexColumn(ColumnKeyDescription, "Description", 4), + } + + observationsTableColumns := []table.Column{ + table.NewFlexColumn(ColumnKeyName, "Observation", 1).WithFiltered(true), + table.NewFlexColumn(ColumnKeyStatus, "Status", 1), + table.NewFlexColumn(ColumnKeyControlIds, "Controls", 1), + table.NewFlexColumn(ColumnKeyDescription, "Remarks", 4), + } + + findingsHeight, findingsWidth := getTableDimensions(m.findingsSummary.Height, m.findingsSummary.Width) + observationsHeight, observationsWidth := getTableDimensions(m.observationsSummary.Height, m.observationsSummary.Width) + + findingsTable = table.New(findingsTableColumns). + WithRows(findingsRows). + WithBaseStyle(common.TableStyleBase). + Filtered(true). + SortByAsc(ColumnKeyName). + WithTargetWidth(findingsWidth). + WithPageSize(findingsHeight) + + observationsTable = table.New(observationsTableColumns). + WithRows(observationsRows). + WithBaseStyle(common.TableStyleBase). + Filtered(true). + SortByAsc(ColumnKeyName). + WithTargetWidth(observationsWidth). + WithPageSize(observationsHeight) + + return findingsTable, observationsTable +} + +func (m *Model) getComparedResultTables(selectedResult, comparedResult result) (findingsTable table.Model, observationsTable table.Model, currentObservations []table.Row) { + findingsRows, observationsRows := GetResultComparison(selectedResult, comparedResult) + + // Set up tables + findingsTableColumns := []table.Column{ + table.NewFlexColumn(ColumnKeyName, "Control", 1).WithFiltered(true), + table.NewFlexColumn(ColumnKeyStatus, "Status", 1), + table.NewFlexColumn(ColumnKeyStatusChange, "Status Change", 1).WithFiltered(true), + table.NewFlexColumn(ColumnKeyDescription, "Description", 4), + } + + observationsTableColumns := []table.Column{ + table.NewFlexColumn(ColumnKeyName, "Observation", 1).WithFiltered(true), + table.NewFlexColumn(ColumnKeyStatus, "Status", 1), + table.NewFlexColumn(ColumnKeyStatusChange, "Status Change", 1).WithFiltered(true), + table.NewFlexColumn(ColumnKeyControlIds, "Controls", 1).WithFiltered(true), + table.NewFlexColumn(ColumnKeyDescription, "Remarks", 4), + } + + findingsHeight, findingsWidth := getTableDimensions(m.findingsSummary.Height, m.findingsSummary.Width) + observationsHeight, observationsWidth := getTableDimensions(m.observationsSummary.Height, m.observationsSummary.Width) + + findingsTable = table.New(findingsTableColumns). + WithRows(findingsRows). + WithBaseStyle(common.TableStyleBase). + Filtered(true). + SortByAsc(ColumnKeyName). + WithTargetWidth(findingsWidth). + WithPageSize(findingsHeight) + + observationsTable = table.New(observationsTableColumns). + WithRows(observationsRows). + WithBaseStyle(common.TableStyleBase). + Filtered(true). + SortByAsc(ColumnKeyName). + WithTargetWidth(observationsWidth). + WithPageSize(observationsHeight) + + return findingsTable, observationsTable, observationsRows +} + +func getTableDimensions(parentHeight, parentWidth int) (height int, width int) { + height = parentHeight - common.PanelTitleStyle.GetHeight() - common.PanelStyle.GetVerticalPadding() - 6 + width = parentWidth - common.PanelStyle.GetHorizontalPadding() - common.PanelStyle.GetHorizontalMargins() - 2 + return height, width +} + +func (m *Model) getFindingsDetail() string { + var text strings.Builder + important := lipgloss.NewStyle().Bold(true). + Foreground(common.Special) + + text.WriteString(fmt.Sprintf("%s\n\n", important.Render("Finding: "+m.findingsTable.HighlightedRow().Data[ColumnKeyName].(string)))) + text.WriteString(m.findingsTable.HighlightedRow().Data[ColumnKeyFinding].(string)) + + if m.comparedResult.OscalResult != nil { + text.WriteString(fmt.Sprintf("\n\n%s\n\n", important.Render("Compared Finding: "+m.findingsTable.HighlightedRow().Data[ColumnKeyName].(string)))) + text.WriteString(m.findingsTable.HighlightedRow().Data[ColumnKeyComparedFinding].(string)) + } + + return text.String() +} + +func (m *Model) getObsDetail() string { + var text strings.Builder + important := lipgloss.NewStyle().Bold(true). + Foreground(common.Special) + + text.WriteString(fmt.Sprintf("Control IDs: %s\n\n", m.observationsTable.HighlightedRow().Data[ColumnKeyControlIds].(string))) + text.WriteString(fmt.Sprintf("%s\n\n", important.Render("Observation: "+m.observationsTable.HighlightedRow().Data[ColumnKeyName].(string)))) + text.WriteString(m.observationsTable.HighlightedRow().Data[ColumnKeyObservation].(string)) + + if m.comparedResult.OscalResult != nil { + text.WriteString(fmt.Sprintf("\n\n%s\n\n", important.Render("Compared Observation: "+m.observationsTable.HighlightedRow().Data[ColumnKeyName].(string)))) + text.WriteString(m.observationsTable.HighlightedRow().Data[ColumnKeyComparedObservation].(string)) + } + + return text.String() +} diff --git a/src/internal/tui/assessment_results/model_test.go b/src/internal/tui/assessment_results/model_test.go new file mode 100644 index 00000000..a54556fd --- /dev/null +++ b/src/internal/tui/assessment_results/model_test.go @@ -0,0 +1,122 @@ +package assessmentresults_test + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/defenseunicorns/lula/src/internal/testhelpers" + assessmentresults "github.com/defenseunicorns/lula/src/internal/tui/assessment_results" + "github.com/defenseunicorns/lula/src/internal/tui/common" + "github.com/muesli/termenv" + "github.com/stretchr/testify/require" +) + +const ( + timeout = time.Second * 20 + maxRetries = 3 + height = common.DefaultHeight + width = common.DefaultWidth + + validAssessmentResults = "../../../test/unit/common/oscal/valid-assessment-results.yaml" + validAssessmentResultsMulti = "../../../test/unit/common/oscal/valid-assessment-results-multi.yaml" + validAssessmentResultsRemovedFinding = "../../../test/unit/common/oscal/valid-assessment-results-removed-finding.yaml" + validAssessmentResultsAddedFinding = "../../../test/unit/common/oscal/valid-assessment-results-added-finding.yaml" + validAssessmentResultsRemovedObs = "../../../test/unit/common/oscal/valid-assessment-results-removed-observation.yaml" +) + +func init() { + lipgloss.SetColorProfile(termenv.Ascii) +} + +// TestAssessmentResultsBasicView tests that the model is created correctly from an assessment results model +func TestAssessmentResultsBasicView(t *testing.T) { + oscalModel := testhelpers.OscalFromPath(t, validAssessmentResults) + model := assessmentresults.NewAssessmentResultsModel(oscalModel.AssessmentResults) + model.Open(height, width) + + msgs := []tea.Msg{} + + err := testhelpers.RunTestModelView(t, model, nil, msgs, timeout, maxRetries, height, width) + require.NoError(t, err) +} + +// TestAssessmentResultsWithResultsSwitch tests that the model can switch between results +func TestAssessmentResultsWithResultsSwitch(t *testing.T) { + oscalModel := testhelpers.OscalFromPath(t, validAssessmentResultsMulti) + model := assessmentresults.NewAssessmentResultsModel(oscalModel.AssessmentResults) + model.Open(height, width) + mdl, _ := model.Update(tea.WindowSizeMsg{Width: width, Height: height}) + model = mdl.(assessmentresults.Model) + + msgs := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight}, // Select result + tea.KeyMsg{Type: tea.KeyEnter}, // Open result detail + tea.KeyMsg{Type: tea.KeyDown}, // Navigate to next result + tea.KeyMsg{Type: tea.KeyEnter}, // Select result + tea.KeyMsg{Type: tea.KeyRight}, // Navigate to compared result + tea.KeyMsg{Type: tea.KeyRight}, // Navigate to findings table + tea.KeyMsg{Type: tea.KeyEnter}, // Select finding to filter results + } + + err := testhelpers.RunTestModelView(t, model, nil, msgs, timeout, maxRetries, height, width) + require.NoError(t, err) +} + +// TestAssessmentResultsWithFindingsDetail tests that the model can open the findings detail view +func TestAssessmentResultsWithFindingsDetail(t *testing.T) { + oscalModel := testhelpers.OscalFromPath(t, validAssessmentResults) + model := assessmentresults.NewAssessmentResultsModel(oscalModel.AssessmentResults) + model.Open(height, width) + + msgs := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight}, // Select result + tea.KeyMsg{Type: tea.KeyRight}, // Navigate to compared result + tea.KeyMsg{Type: tea.KeyRight}, // Navigate to findings table + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}, // Detail finding + } + + err := testhelpers.RunTestModelView(t, model, nil, msgs, timeout, maxRetries, height, width) + require.NoError(t, err) +} + +// TestAssessmentResultsWithComparison tests that the model can show the comparison between two results +func TestAssessmentResultsWithComparison(t *testing.T) { + oscalModel := testhelpers.OscalFromPath(t, validAssessmentResultsMulti) + model := assessmentresults.NewAssessmentResultsModel(oscalModel.AssessmentResults) + model.Open(height, width) + + msgs := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight}, // Select result + tea.KeyMsg{Type: tea.KeyRight}, // Navigate to compared result + tea.KeyMsg{Type: tea.KeyEnter}, // Open compared result detail + tea.KeyMsg{Type: tea.KeyDown}, // Navigate to next result + tea.KeyMsg{Type: tea.KeyEnter}, // Select result + tea.KeyMsg{Type: tea.KeyRight}, // Navigate to findings table + } + + err := testhelpers.RunTestModelView(t, model, nil, msgs, timeout, maxRetries, height, width) + require.NoError(t, err) +} + +// TestAssessmentResultsWithComparison tests that the model can show the comparison between two results +func TestAssessmentResultsWithObservationComparisonDetail(t *testing.T) { + oscalModel := testhelpers.OscalFromPath(t, validAssessmentResultsMulti) + model := assessmentresults.NewAssessmentResultsModel(oscalModel.AssessmentResults) + model.Open(height, width) + + msgs := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight}, // Select result + tea.KeyMsg{Type: tea.KeyRight}, // Navigate to compared result + tea.KeyMsg{Type: tea.KeyEnter}, // Open compared result detail + tea.KeyMsg{Type: tea.KeyDown}, // Navigate to next result + tea.KeyMsg{Type: tea.KeyEnter}, // Select result + tea.KeyMsg{Type: tea.KeyRight}, // Navigate to findings table + tea.KeyMsg{Type: tea.KeyRight}, // Navigate to observations table + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}, // Detail observation + } + + err := testhelpers.RunTestModelView(t, model, nil, msgs, timeout, maxRetries, height, width) + require.NoError(t, err) +} diff --git a/src/internal/tui/assessment_results/testdata/TestAssessmentResultsBasicView.golden b/src/internal/tui/assessment_results/testdata/TestAssessmentResultsBasicView.golden index 02d559d7..a050054f 100644 --- a/src/internal/tui/assessment_results/testdata/TestAssessmentResultsBasicView.golden +++ b/src/internal/tui/assessment_results/testdata/TestAssessmentResultsBasicView.golden @@ -1,56 +1,55 @@ - ? toggle help + ←/h, →/l navigation • tab/shift+tab switch models • ? toggle help ╭────────────────────────────────────────╮ ╭────────────────────────────────────────╮ - Selected Result │Lula Validation Result - 41787700-2a4c-…│ Compare Result │No Result Selected │ + Selected Result │Lula Validation Result, https://github.…│ Compare Result │No Result Selected │ ╰────────────────────────────────────────╯ ╰────────────────────────────────────────╯ -╭───────────────╮ ╭─────────╮ -│ Findings List ├─────────────────────────────╮ │ Summary ├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -╰───────────────╯ ╰─────────╯ - │ │ │ ⚠️ Summary Under Construction ⚠️ │ - │ 1 item │ │ │ - │ │ │ │ - │ ID-1 │ │ │ - │ not-satisfied │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - │ │ ╭──────────────╮ - │ │ │ Observations ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │ │ ╰──────────────╯ - │ │ │ ⚠️ Observations Under Construction ⚠️ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ ↑/k up • ↓/j down • ↳ confirm • ? toggle │ │ │ - ╰────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file + Summary: 1 (1/0) Findings - 1 (1/0) Observations +╭──────────╮ +│ Findings ├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +╰──────────╯ + │ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │ + │ ┃Control ┃Status ┃Description ┃ │ + │ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │ + │ ┃ID-1 ┃satisfied ┃Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 42C2FFDC-5F05-44DF-A67F-EEC8660AEFF…┃ │ + │ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │ + │ ┃1/1 ┃ │ + │ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭──────────────╮ +│ Observations ├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +╰──────────────╯ + │ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │ + │ ┃Observation ┃Status ┃Controls ┃Remarks ┃ │ + │ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │ + │ ┃Validate pods with label f…┃satisfied ┃ID-1 ┃validate.msg: Pod label foo=bar is satisfied ┃ │ + │ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │ + │ ┃1/1 ┃ │ + │ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/internal/tui/assessment_results/testdata/TestAssessmentResultsWithComparison.golden b/src/internal/tui/assessment_results/testdata/TestAssessmentResultsWithComparison.golden new file mode 100644 index 00000000..be91571d --- /dev/null +++ b/src/internal/tui/assessment_results/testdata/TestAssessmentResultsWithComparison.golden @@ -0,0 +1,55 @@ + ↑/k move up • ↓/j move down • / filter • d detail • ↳ select • ? toggle help + ╭────────────────────────────────────────╮ ╭────────────────────────────────────────╮ + Selected Result │Lula Validation Result, https://github.…│ Compare Result │Lula Validation Result, https://github.…│ + ╰────────────────────────────────────────╯ ╰────────────────────────────────────────╯ + Summary: 2 (0/2) Findings - 2 (0/2) Observations | Compared Summary: 2 (2/0) Findings - 2 (2/0) Observations +╭──────────╮ +│ Findings ├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +╰──────────╯ + │ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │ + │ ┃Control ┃Status ┃Status Change ┃Description ┃ │ + │ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │ + │ ┃ID-1 ┃not-satisfied ┃SATISFIED TO NOT SATISFIED ┃Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 42C2FFDC-5F05-4…┃ │ + │ ┃ID-2 ┃not-satisfied ┃SATISFIED TO NOT SATISFIED ┃Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 86a0e8d9-0ce0-4…┃ │ + │ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │ + │ ┃1/1 ┃ │ + │ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭──────────────╮ +│ Observations ├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +╰──────────────╯ + │ ┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │ + │ ┃Observation ┃Status ┃Status Change ┃Controls ┃Remarks ┃ │ + │ ┣━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │ + │ ┃lula-validation-1 ┃not-satisfied ┃SATISFIED TO NOT SATIS…┃ID-1 ┃validate.msg: Pod label foo is NOT bar ┃ │ + │ ┃lula-validation-2 ┃not-satisfied ┃SATISFIED TO NOT SATIS…┃ID-2 ┃ ┃ │ + │ ┣━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │ + │ ┃1/1 ┃ │ + │ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/internal/tui/assessment_results/testdata/TestAssessmentResultsWithFindingsDetail.golden b/src/internal/tui/assessment_results/testdata/TestAssessmentResultsWithFindingsDetail.golden new file mode 100644 index 00000000..75167cab --- /dev/null +++ b/src/internal/tui/assessment_results/testdata/TestAssessmentResultsWithFindingsDetail.golden @@ -0,0 +1,57 @@ + ╔════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗ + ║ ║ + ║ Finding: ID-1 ║ + ║ ║ + ║ description: | ║ + ║ Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 42C2FFDC-5F05-44DF-A67F-EEC8660AEFFD ║ + ║ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud ║ + ║ exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. ║ + ║ Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. ║ + ║ related-observations: ║ + ║ - observation-uuid: 32c1b7c8-2e64-4786-b589-2d82587d4157 ║ + ║ target: ║ + ║ status: ║ + ║ state: satisfied ║ + ║ target-id: ID-1 ║ + ║ type: objective-id ║ + ║ title: 'Validation Result - Control: ID-1' ║ + ║ uuid: 40d4f7a2-a845-4d7a-9501-1368d4cd3c5b ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ↑/k scroll up • ↓/j scroll down • esc cancel ║ + ╚════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝ \ No newline at end of file diff --git a/src/internal/tui/assessment_results/testdata/TestAssessmentResultsWithObservationComparisonDetail.golden b/src/internal/tui/assessment_results/testdata/TestAssessmentResultsWithObservationComparisonDetail.golden new file mode 100644 index 00000000..1e09998e --- /dev/null +++ b/src/internal/tui/assessment_results/testdata/TestAssessmentResultsWithObservationComparisonDetail.golden @@ -0,0 +1,57 @@ + ╔════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗ + ║ ║ + ║ Control IDs: ID-1 ║ + ║ ║ + ║ Observation: lula-validation-1 ║ + ║ ║ + ║ collected: 2024-10-15T10:56:06.553304-04:00 ║ + ║ description: | ║ + ║ [TEST]: 88AB3470-B96B-4D7C-BC36-02BF9563C46C - lula-validation-1 ║ + ║ methods: ║ + ║ - TEST ║ + ║ relevant-evidence: ║ + ║ - description: | ║ + ║ Result: not-satisfied ║ + ║ remarks: | ║ + ║ validate.msg: Pod label foo is NOT bar ║ + ║ uuid: 92cb3cad-bbcd-431a-aaa9-cd47275a3982 ║ + ║ ║ + ║ ║ + ║ Compared Observation: lula-validation-1 ║ + ║ ║ + ║ collected: 2024-10-15T10:55:51.725223-04:00 ║ + ║ description: | ║ + ║ [TEST]: 88AB3470-B96B-4D7C-BC36-02BF9563C46C - lula-validation-1 ║ + ║ methods: ║ + ║ - TEST ║ + ║ relevant-evidence: ║ + ║ - description: | ║ + ║ Result: satisfied ║ + ║ remarks: | ║ + ║ validate.msg: Pod label foo=bar is satisfied ║ + ║ uuid: 0e7fc8b3-b230-49f9-be7c-8f011536dfb4 ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ║ + ║ ↑/k scroll up • ↓/j scroll down • esc cancel ║ + ╚════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝ \ No newline at end of file diff --git a/src/internal/tui/assessment_results/testdata/TestAssessmentResultsWithResultsSwitch.golden b/src/internal/tui/assessment_results/testdata/TestAssessmentResultsWithResultsSwitch.golden new file mode 100644 index 00000000..0791fda0 --- /dev/null +++ b/src/internal/tui/assessment_results/testdata/TestAssessmentResultsWithResultsSwitch.golden @@ -0,0 +1,55 @@ + ↑/k move up • ↓/j move down • / filter • d detail • ↳ select • ? toggle help + ╭────────────────────────────────────────╮ ╭────────────────────────────────────────╮ + Selected Result │Lula Validation Result, https://github.…│ Compare Result │No Result Selected │ + ╰────────────────────────────────────────╯ ╰────────────────────────────────────────╯ + Summary: 2 (2/0) Findings - 2 (2/0) Observations +╭──────────╮ +│ Findings ├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +╰──────────╯ + │ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │ + │ ┃Control ┃Status ┃Description ┃ │ + │ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │ + │ ┃ID-1 ┃satisfied ┃Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 42C2FFDC-5F05-44DF-A67F-EEC8660AEFF…┃ │ + │ ┃ID-2 ┃satisfied ┃Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 86a0e8d9-0ce0-4304-afe7-4c000001e03…┃ │ + │ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │ + │ ┃1/1 ┃ │ + │ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭──────────────╮ +│ Observations ├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +╰──────────────╯ + │ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │ + │ ┃Observation ┃Status ┃Controls ┃Remarks ┃ │ + │ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │ + │ ┃lula-validation-1 ┃satisfied ┃ID-1 ┃validate.msg: Pod label foo=bar is satisfied ┃ │ + │ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │ + │ ┃1/1 ┃ │ + │ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/internal/tui/assessment_results/types.go b/src/internal/tui/assessment_results/types.go deleted file mode 100644 index 109f352e..00000000 --- a/src/internal/tui/assessment_results/types.go +++ /dev/null @@ -1,98 +0,0 @@ -package assessmentresults - -import ( - blist "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/viewport" - oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" - "github.com/defenseunicorns/lula/src/internal/tui/common" -) - -type Model struct { - open bool - help common.HelpModel - keys keys - focus focus - inResultOverlay bool - results []result - resultsPicker viewport.Model - selectedResult result - selectedResultIndex int - compareResult result - compareResultIndex int - findings blist.Model - findingPicker viewport.Model - findingSummary viewport.Model - observationSummary viewport.Model - width int - height int -} - -type focus int - -const ( - noFocus focus = iota - focusResultSelection - focusCompareSelection - focusFindings - focusSummary - focusObservations -) - -var maxFocus = focusObservations - -type result struct { - uuid, title string - findings *[]oscalTypes_1_1_2.Finding - observations *[]oscalTypes_1_1_2.Observation -} - -type finding struct { - title, uuid, controlId, state string - observations []observation -} - -func (i finding) Title() string { return i.controlId } -func (i finding) Description() string { return i.state } -func (i finding) FilterValue() string { return i.title } - -type observation struct { - uuid, description, remarks, state, validationId string -} - -func (m *Model) Close() { - m.open = false -} - -func (m *Model) Open(height, width int) { - m.open = true - m.UpdateSizing(height, width) -} - -func (m *Model) UpdateSizing(height, width int) { - m.height = height - m.width = width - - totalHeight := m.height - leftWidth := m.width / 4 - rightWidth := m.width - leftWidth - common.PanelStyle.GetHorizontalPadding() - common.PanelStyle.GetHorizontalMargins() - - topSectionHeight := common.HelpStyle(m.width).GetHeight() + common.DialogBoxStyle.GetHeight() - bottomSectionHeight := totalHeight - topSectionHeight - bottomRightPanelHeight := (bottomSectionHeight - 2*common.PanelTitleStyle.GetHeight() - 2*common.PanelTitleStyle.GetVerticalMargins()) / 2 - - m.findings.SetHeight(totalHeight - topSectionHeight - common.PanelTitleStyle.GetHeight() - common.PanelStyle.GetVerticalPadding()) - m.findings.SetWidth(leftWidth - common.PanelStyle.GetHorizontalPadding()) - - m.findingPicker.Height = bottomSectionHeight - m.findingPicker.Width = leftWidth - common.PanelStyle.GetHorizontalPadding() - - m.findingSummary.Height = bottomRightPanelHeight - m.findingSummary.Width = rightWidth - - m.observationSummary.Height = bottomRightPanelHeight - m.observationSummary.Width = rightWidth -} - -func (m *Model) GetDimensions() (height, width int) { - return m.height, m.width -} diff --git a/src/internal/tui/common/common.go b/src/internal/tui/common/common.go index 792100b4..3d45a46e 100644 --- a/src/internal/tui/common/common.go +++ b/src/internal/tui/common/common.go @@ -1,6 +1,7 @@ package common import ( + "encoding/json" "fmt" "os" @@ -9,6 +10,7 @@ import ( "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" "github.com/davecgh/go-spew/spew" + "github.com/evertras/bubble-table/table" "github.com/mattn/go-runewidth" "gopkg.in/yaml.v3" ) @@ -94,6 +96,26 @@ func UnfocusedPanelKeyMap() viewport.KeyMap { return km } +func FocusedTableKeyMap() table.KeyMap { + km := table.DefaultKeyMap() + km.PageUp = key.NewBinding( + key.WithKeys("pgup"), + key.WithHelp("pgup", "page up"), + ) + km.PageDown = key.NewBinding( + key.WithKeys("pgdown"), + key.WithHelp("pgdown", "page down"), + ) + + return km +} + +func UnfocusedTableKeyMap() table.KeyMap { + km := table.KeyMap{} + + return km +} + func FocusedTextAreaKeyMap() textarea.KeyMap { km := textarea.DefaultKeyMap @@ -132,3 +154,11 @@ func ToYamlString(input interface{}) (string, error) { return string(yamlData), nil } + +func DeepCopy(src, dst interface{}) error { + data, err := json.Marshal(src) + if err != nil { + return err + } + return json.Unmarshal(data, dst) +} diff --git a/src/internal/tui/common/keys.go b/src/internal/tui/common/keys.go index ab21c071..aac2bf50 100644 --- a/src/internal/tui/common/keys.go +++ b/src/internal/tui/common/keys.go @@ -184,7 +184,6 @@ var ( } ) -// Implemented for type editorKeys struct { Confirm key.Binding NewLine key.Binding @@ -223,6 +222,79 @@ var ( } ) +type tableKeys struct { + Up key.Binding + PgUp key.Binding + Down key.Binding + PgDown key.Binding + Select key.Binding + ClearFilter key.Binding + ClearSelection key.Binding + Filter key.Binding + Detail key.Binding +} + +var TableKeys = tableKeys{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "move up"), + ), + PgUp: key.NewBinding( + key.WithKeys("pgup"), + key.WithHelp("pgup", "page up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "move down"), + ), + PgDown: key.NewBinding( + key.WithKeys("pgdn"), + key.WithHelp("pgdn", "page down"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("↳", "select"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + ClearFilter: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "clear filter"), + ), + ClearSelection: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "clear selection"), + ), + Detail: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "detail"), + ), +} + +var ( + ShortHelpTable = []key.Binding{ + TableKeys.Up, TableKeys.Down, TableKeys.Filter, TableKeys.Detail, CommonKeys.Help, + } + FullHelpTableOneLine = []key.Binding{ + TableKeys.Up, TableKeys.Down, TableKeys.Detail, TableKeys.ClearFilter, TableKeys.PgUp, TableKeys.PgDown, CommonKeys.Help, + } + FullHelpTable = [][]key.Binding{ + {TableKeys.Up}, {TableKeys.Down}, {TableKeys.Detail}, {TableKeys.ClearFilter}, {TableKeys.PgUp}, {TableKeys.PgDown}, {CommonKeys.Help}, + } + + ShortHelpTableWithSelect = []key.Binding{ + TableKeys.Up, TableKeys.Down, TableKeys.Filter, TableKeys.Detail, TableKeys.Select, CommonKeys.Help, + } + FullHelpTableWithSelectOneLine = []key.Binding{ + TableKeys.Up, TableKeys.Down, TableKeys.Select, TableKeys.Detail, TableKeys.ClearFilter, TableKeys.ClearSelection, TableKeys.PgUp, TableKeys.PgDown, CommonKeys.Help, + } + FullHelpTableWithSelect = [][]key.Binding{ + {TableKeys.Up}, {TableKeys.Down}, {TableKeys.Select}, {TableKeys.Detail}, {TableKeys.ClearFilter}, {TableKeys.ClearSelection}, {TableKeys.PgUp}, {TableKeys.PgDown}, {CommonKeys.Help}, + } +) + type detailKeys struct { Up key.Binding Down key.Binding diff --git a/src/internal/tui/common/picker.go b/src/internal/tui/common/picker.go index 2ad6ec86..5bb5c40b 100644 --- a/src/internal/tui/common/picker.go +++ b/src/internal/tui/common/picker.go @@ -120,4 +120,5 @@ func (m PickerModel) View() string { func (m *PickerModel) UpdateItems(items []string) { m.items = items + m.selected = 0 } diff --git a/src/internal/tui/common/styles.go b/src/internal/tui/common/styles.go index 91a2cef8..25d17843 100644 --- a/src/internal/tui/common/styles.go +++ b/src/internal/tui/common/styles.go @@ -3,28 +3,17 @@ package common import ( "strings" + "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/lipgloss" ) -const ( - // In real life situations we'd adjust the document to fit the width we've - // detected. In the case of this example we're hardcoding the width, and - // later using the detected width only to truncate in order to avoid jaggy - // wrapping. - width = 96 - - columnWidth = 30 - - modalWidth = 60 - modalHeight = 7 -) - -// Style definitions. +// Common Style definitions. var ( // Colors - + Text = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#ffffff"} Subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} + Subtle2 = lipgloss.AdaptiveColor{Light: "#706f6f", Dark: "#989797"} Highlight = lipgloss.AdaptiveColor{Light: "#6d26fc", Dark: "#7D56F4"} Highlight2 = lipgloss.AdaptiveColor{Light: "#8f58fc", Dark: "#8f6ef0"} Focused = lipgloss.AdaptiveColor{Light: "#8378ab", Dark: "#bfb2eb"} @@ -102,7 +91,7 @@ var ( OverlayStyle = lipgloss.NewStyle(). Border(lipgloss.DoubleBorder(), true). BorderForeground(Focused). - Padding(1, 1) + Padding(1, 1, 0, 1) OverlayWarnStyle = lipgloss.NewStyle(). Border(lipgloss.DoubleBorder(), true). @@ -116,6 +105,32 @@ var ( Margin(1). Width(30) + SummaryTextStyle = lipgloss.NewStyle(). + Foreground(Text).Margin(0, 1) + + // Table Styles + TableStyles = table.Styles{ + Header: lipgloss.NewStyle().Foreground(Highlight).Bold(true), + Cell: lipgloss.NewStyle().Foreground(Subtle), + Selected: lipgloss.NewStyle().Foreground(Highlight), + } + + UnfocusedTableStyles = table.Styles{ + Header: lipgloss.NewStyle().Foreground(Highlight).Bold(true), + Cell: lipgloss.NewStyle().Foreground(Subtle), + Selected: lipgloss.NewStyle().Foreground(Subtle), + } + + TableStyleBase = lipgloss.NewStyle(). + Foreground(Text). + BorderForeground(Subtle2). + Align(lipgloss.Left) + + TableStyleActive = lipgloss.NewStyle(). + Foreground(Text). + BorderForeground(Highlight). + Align(lipgloss.Left) + // Help KeyStyle = lipgloss.NewStyle().Foreground(HelpKey) DescStyle = lipgloss.NewStyle().Foreground(HelpDesc) diff --git a/src/internal/tui/component/focus.go b/src/internal/tui/component/focus.go index c8d6aab2..88df7b72 100644 --- a/src/internal/tui/component/focus.go +++ b/src/internal/tui/component/focus.go @@ -1,5 +1,9 @@ package component +import ( + "github.com/defenseunicorns/lula/src/internal/tui/common" +) + type focus int const ( @@ -13,3 +17,121 @@ const ( ) var maxFocus = focusValidations + +func (m *Model) updateKeyBindings() { + m.outOfFocus() + m.updateFocusHelpKeys() + + switch m.focus { + + case focusControls: + m.controls.KeyMap = common.FocusedListKeyMap() + m.controls.SetDelegate(common.NewFocusedDelegate()) + + case focusValidations: + m.validations.KeyMap = common.FocusedListKeyMap() + m.validations.SetDelegate(common.NewFocusedDelegate()) + + case focusRemarks: + m.remarks.KeyMap = common.FocusedPanelKeyMap() + m.remarks.MouseWheelEnabled = true + if m.remarksEditor.Focused() { + m.remarksEditor.KeyMap = common.FocusedTextAreaKeyMap() + m.keys = componentEditKeys + } else { + m.remarksEditor.KeyMap = common.UnfocusedTextAreaKeyMap() + m.keys = componentKeys + } + + case focusDescription: + m.description.KeyMap = common.FocusedPanelKeyMap() + m.description.MouseWheelEnabled = true + if m.descriptionEditor.Focused() { + m.descriptionEditor.KeyMap = common.FocusedTextAreaKeyMap() + m.keys = componentEditKeys + } else { + m.descriptionEditor.KeyMap = common.UnfocusedTextAreaKeyMap() + m.keys = componentKeys + } + + } +} + +// func for outOfFocus to run just when focus switches between items +func (m *Model) outOfFocus() { + focusMinusOne := m.focus - 1 + focusPlusOne := m.focus + 1 + + if m.focus == 0 { + focusMinusOne = maxFocus + } + if m.focus == maxFocus { + focusPlusOne = 0 + } + + for _, f := range []focus{focusMinusOne, focusPlusOne} { + // Turn off keys for out of focus items + switch f { + case focusControls: + m.controls.KeyMap = common.UnfocusedListKeyMap() + + case focusValidations: + m.validations.KeyMap = common.UnfocusedListKeyMap() + m.validations.SetDelegate(common.NewUnfocusedDelegate()) + m.validations.ResetSelected() + + case focusRemarks: + m.remarks.KeyMap = common.UnfocusedPanelKeyMap() + m.remarks.MouseWheelEnabled = false + + case focusDescription: + m.description.KeyMap = common.UnfocusedPanelKeyMap() + m.description.MouseWheelEnabled = false + } + } +} + +func (m *Model) updateFocusHelpKeys() { + switch m.focus { + case focusComponentSelection: + m.help.ShortHelp = shortHelpDialogBox + m.help.FullHelpOneLine = fullHelpDialogBoxOneLine + m.help.FullHelp = fullHelpDialogBox + case focusFrameworkSelection: + m.help.ShortHelp = shortHelpDialogBox + m.help.FullHelpOneLine = fullHelpDialogBoxOneLine + m.help.FullHelp = fullHelpDialogBox + case focusControls: + m.help.ShortHelp = common.ShortHelpList + m.help.FullHelpOneLine = common.FullHelpListOneLine + m.help.FullHelp = common.FullHelpList + case focusRemarks: + if m.remarksEditor.Focused() { + m.help.ShortHelp = common.ShortHelpEditing + m.help.FullHelpOneLine = common.FullHelpEditingOneLine + m.help.FullHelp = common.FullHelpEditing + } else { + m.help.ShortHelp = shortHelpEditableDialogBox + m.help.FullHelpOneLine = fullHelpEditableDialogBoxOneLine + m.help.FullHelp = fullHelpEditableDialogBox + } + case focusDescription: + if m.descriptionEditor.Focused() { + m.help.ShortHelp = common.ShortHelpEditing + m.help.FullHelpOneLine = common.FullHelpEditingOneLine + m.help.FullHelp = common.FullHelpEditing + } else { + m.help.ShortHelp = shortHelpEditableDialogBox + m.help.FullHelpOneLine = fullHelpEditableDialogBoxOneLine + m.help.FullHelp = fullHelpEditableDialogBox + } + case focusValidations: + m.help.ShortHelp = shortHelpValidations + m.help.FullHelpOneLine = fullHelpValidationsOneLine + m.help.FullHelp = fullHelpValidations + default: + m.help.ShortHelp = shortHelpNoFocus + m.help.FullHelpOneLine = fullHelpNoFocusOneLine + m.help.FullHelp = fullHelpNoFocus + } +} diff --git a/src/internal/tui/component/model.go b/src/internal/tui/component/model.go index d6b9e5da..bd7ef4ef 100644 --- a/src/internal/tui/component/model.go +++ b/src/internal/tui/component/model.go @@ -13,9 +13,11 @@ import ( ) const ( - height = 20 - width = 12 - dialogFixedWidth = 40 + componentPickerKind common.PickerKind = "component" + frameworkPickerKind common.PickerKind = "framework" + height = 20 + width = 12 + dialogFixedWidth = 40 ) type Model struct { @@ -45,10 +47,11 @@ type Model struct { height int } -const ( - componentPickerKind common.PickerKind = "component" - frameworkPickerKind common.PickerKind = "framework" -) +type ModelOpenMsg struct { + Height int + Width int +} +type ModelCloseMsg struct{} func NewComponentDefinitionModel(oscalComponent *oscalTypes_1_1_2.ComponentDefinition) Model { var selectedComponent component @@ -137,145 +140,146 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { + case ModelOpenMsg: + m.Open(msg.Height, msg.Width) + case tea.WindowSizeMsg: m.updateSizing(msg.Height-common.TabOffset, msg.Width) case tea.KeyMsg: - if m.open { - k := msg.String() - switch k { - case common.ContainsKey(k, m.keys.Help.Keys()): - m.help.ShowAll = !m.help.ShowAll - - case common.ContainsKey(k, m.keys.NavigateLeft.Keys()): - if !m.componentPicker.Open && !m.frameworkPicker.Open && !m.detailView.Open { - if m.focus == 0 { - m.focus = maxFocus - } else { - m.focus-- - } - m.updateKeyBindings() + k := msg.String() + switch k { + case common.ContainsKey(k, m.keys.Help.Keys()): + m.help.ShowAll = !m.help.ShowAll + + case common.ContainsKey(k, m.keys.NavigateLeft.Keys()): + if !m.inOverlay() { + if m.focus == 0 { + m.focus = maxFocus + } else { + m.focus-- } + m.updateKeyBindings() + } - case common.ContainsKey(k, m.keys.NavigateRight.Keys()): - if !m.componentPicker.Open && !m.frameworkPicker.Open && !m.detailView.Open { - m.focus = (m.focus + 1) % (maxFocus + 1) - m.updateKeyBindings() - } + case common.ContainsKey(k, m.keys.NavigateRight.Keys()): + if !m.inOverlay() { + m.focus = (m.focus + 1) % (maxFocus + 1) + m.updateKeyBindings() + } - case common.ContainsKey(k, m.keys.Confirm.Keys()): - switch m.focus { - case focusComponentSelection: - if len(m.components) > 0 && !m.componentPicker.Open { - return m, func() tea.Msg { - return common.PickerOpenMsg{ - Kind: componentPickerKind, - } + case common.ContainsKey(k, m.keys.Confirm.Keys()): + switch m.focus { + case focusComponentSelection: + if len(m.components) > 0 && !m.componentPicker.Open { + return m, func() tea.Msg { + return common.PickerOpenMsg{ + Kind: componentPickerKind, } } + } - case focusFrameworkSelection: - if len(m.frameworks) > 0 && !m.frameworkPicker.Open { - return m, func() tea.Msg { - return common.PickerOpenMsg{ - Kind: frameworkPickerKind, - } + case focusFrameworkSelection: + if len(m.frameworks) > 0 && !m.frameworkPicker.Open { + return m, func() tea.Msg { + return common.PickerOpenMsg{ + Kind: frameworkPickerKind, } } + } - case focusControls: - if selectedItem := m.controls.SelectedItem(); selectedItem != nil { - m.selectedControl = m.controls.SelectedItem().(control) - m.remarks.SetContent(m.selectedControl.OscalControl.Remarks) - m.description.SetContent(m.selectedControl.OscalControl.Description) + case focusControls: + if selectedItem := m.controls.SelectedItem(); selectedItem != nil { + m.selectedControl = m.controls.SelectedItem().(control) + m.remarks.SetContent(m.selectedControl.OscalControl.Remarks) + m.description.SetContent(m.selectedControl.OscalControl.Description) - // update validations list for selected control - validationItems := make([]blist.Item, len(m.selectedControl.Validations)) - for i, val := range m.selectedControl.Validations { - validationItems[i] = val - } - m.validations.SetItems(validationItems) + // update validations list for selected control + validationItems := make([]blist.Item, len(m.selectedControl.Validations)) + for i, val := range m.selectedControl.Validations { + validationItems[i] = val } + m.validations.SetItems(validationItems) + } - case focusValidations: - if selectedItem := m.validations.SelectedItem(); selectedItem != nil { - m.selectedValidation = selectedItem.(validationLink) - } + case focusValidations: + if selectedItem := m.validations.SelectedItem(); selectedItem != nil { + m.selectedValidation = selectedItem.(validationLink) + } + case focusRemarks: + if m.remarksEditor.Focused() { + remarks := m.remarksEditor.Value() + m.UpdateRemarks(remarks) + m.remarksEditor.Blur() + m.remarks.SetContent(remarks) + m.updateKeyBindings() + } + + case focusDescription: + if m.descriptionEditor.Focused() { + description := m.descriptionEditor.Value() + m.UpdateDescription(description) + m.descriptionEditor.Blur() + m.description.SetContent(description) + m.updateKeyBindings() + } + } + + case common.ContainsKey(k, m.keys.Edit.Keys()): + if m.selectedControl.OscalControl != nil { + switch m.focus { case focusRemarks: - if m.remarksEditor.Focused() { - remarks := m.remarksEditor.Value() - m.UpdateRemarks(remarks) - m.remarksEditor.Blur() - m.remarks.SetContent(remarks) + if !m.remarksEditor.Focused() { + m.remarksEditor.SetValue(m.selectedControl.OscalControl.Remarks) + m.remarks.SetContent(m.remarksEditor.View()) + _ = m.remarksEditor.Focus() m.updateKeyBindings() } - case focusDescription: - if m.descriptionEditor.Focused() { - description := m.descriptionEditor.Value() - m.UpdateDescription(description) - m.descriptionEditor.Blur() - m.description.SetContent(description) + if !m.descriptionEditor.Focused() { + m.descriptionEditor.SetValue(m.selectedControl.OscalControl.Description) + m.description.SetContent(m.descriptionEditor.View()) + _ = m.descriptionEditor.Focus() m.updateKeyBindings() } } + } - case common.ContainsKey(k, m.keys.Edit.Keys()): - if m.selectedControl.OscalControl != nil { - switch m.focus { - case focusRemarks: - if !m.remarksEditor.Focused() { - m.remarksEditor.SetValue(m.selectedControl.OscalControl.Remarks) - m.remarks.SetContent(m.remarksEditor.View()) - _ = m.remarksEditor.Focus() - m.updateKeyBindings() - } - case focusDescription: - if !m.descriptionEditor.Focused() { - m.descriptionEditor.SetValue(m.selectedControl.OscalControl.Description) - m.description.SetContent(m.descriptionEditor.View()) - _ = m.descriptionEditor.Focus() - m.updateKeyBindings() + case common.ContainsKey(k, m.keys.Detail.Keys()): + switch m.focus { + case focusValidations: + // TODO: update the key locks + if selectedItem := m.validations.SelectedItem(); selectedItem != nil { + valLink := selectedItem.(validationLink) + m.validations.KeyMap = common.UnfocusedListKeyMap() + return m, func() tea.Msg { + return common.DetailOpenMsg{ + Content: getValidationText(valLink), + WindowHeight: (m.height + common.TabOffset), + WindowWidth: m.width, } } } + } - case common.ContainsKey(k, m.keys.Detail.Keys()): + case common.ContainsKey(k, m.keys.Cancel.Keys()): + if m.selectedControl.OscalControl != nil { switch m.focus { - case focusValidations: - // TODO: update the key locks - if selectedItem := m.validations.SelectedItem(); selectedItem != nil { - valLink := selectedItem.(validationLink) - m.validations.KeyMap = common.UnfocusedListKeyMap() - return m, func() tea.Msg { - return common.DetailOpenMsg{ - Content: getValidationText(valLink), - WindowHeight: (m.height + common.TabOffset), - WindowWidth: m.width, - } - } + case focusRemarks: + if m.remarksEditor.Focused() { + m.remarksEditor.Blur() + m.remarks.SetContent(m.selectedControl.OscalControl.Remarks) } - } - - case common.ContainsKey(k, m.keys.Cancel.Keys()): - if m.selectedControl.OscalControl != nil { - switch m.focus { - case focusRemarks: - if m.remarksEditor.Focused() { - m.remarksEditor.Blur() - m.remarks.SetContent(m.selectedControl.OscalControl.Remarks) - } - case focusDescription: - if m.descriptionEditor.Focused() { - m.descriptionEditor.Blur() - m.description.SetContent(m.selectedControl.OscalControl.Description) - } + case focusDescription: + if m.descriptionEditor.Focused() { + m.descriptionEditor.Blur() + m.description.SetContent(m.selectedControl.OscalControl.Description) } } - m.updateKeyBindings() } + m.updateKeyBindings() } case common.PickerItemSelected: @@ -332,6 +336,7 @@ func (m Model) View() string { if m.detailView.Open { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.detailView.View(), lipgloss.WithWhitespaceChars(" ")) } + return m.mainView() } @@ -509,6 +514,10 @@ func (m *Model) UpdateDescription(description string) { } } +func (m *Model) inOverlay() bool { + return m.componentPicker.Open || m.frameworkPicker.Open || m.detailView.Open +} + func (m *Model) resetWidgets() { if m.selectedFramework.OscalFramework != nil { controlItems := make([]blist.Item, len(m.selectedFramework.Controls)) @@ -585,121 +594,3 @@ func (m *Model) updateSizing(height, width int) { m.validationPicker.Height = validationsHeight m.validationPicker.Width = rightWidth } - -func (m *Model) updateKeyBindings() { - m.outOfFocus() - m.updateFocusHelpKeys() - - switch m.focus { - - case focusControls: - m.controls.KeyMap = common.FocusedListKeyMap() - m.controls.SetDelegate(common.NewFocusedDelegate()) - - case focusValidations: - m.validations.KeyMap = common.FocusedListKeyMap() - m.validations.SetDelegate(common.NewFocusedDelegate()) - - case focusRemarks: - m.remarks.KeyMap = common.FocusedPanelKeyMap() - m.remarks.MouseWheelEnabled = true - if m.remarksEditor.Focused() { - m.remarksEditor.KeyMap = common.FocusedTextAreaKeyMap() - m.keys = componentEditKeys - } else { - m.remarksEditor.KeyMap = common.UnfocusedTextAreaKeyMap() - m.keys = componentKeys - } - - case focusDescription: - m.description.KeyMap = common.FocusedPanelKeyMap() - m.description.MouseWheelEnabled = true - if m.descriptionEditor.Focused() { - m.descriptionEditor.KeyMap = common.FocusedTextAreaKeyMap() - m.keys = componentEditKeys - } else { - m.descriptionEditor.KeyMap = common.UnfocusedTextAreaKeyMap() - m.keys = componentKeys - } - - } -} - -// func for outOfFocus to run just when focus switches between items -func (m *Model) outOfFocus() { - focusMinusOne := m.focus - 1 - focusPlusOne := m.focus + 1 - - if m.focus == 0 { - focusMinusOne = maxFocus - } - if m.focus == maxFocus { - focusPlusOne = 0 - } - - for _, f := range []focus{focusMinusOne, focusPlusOne} { - // Turn off keys for out of focus items - switch f { - case focusControls: - m.controls.KeyMap = common.UnfocusedListKeyMap() - - case focusValidations: - m.validations.KeyMap = common.UnfocusedListKeyMap() - m.validations.SetDelegate(common.NewUnfocusedDelegate()) - m.validations.ResetSelected() - - case focusRemarks: - m.remarks.KeyMap = common.UnfocusedPanelKeyMap() - m.remarks.MouseWheelEnabled = false - - case focusDescription: - m.description.KeyMap = common.UnfocusedPanelKeyMap() - m.description.MouseWheelEnabled = false - } - } -} - -func (m *Model) updateFocusHelpKeys() { - switch m.focus { - case focusComponentSelection: - m.help.ShortHelp = shortHelpDialogBox - m.help.FullHelpOneLine = fullHelpDialogBoxOneLine - m.help.FullHelp = fullHelpDialogBox - case focusFrameworkSelection: - m.help.ShortHelp = shortHelpDialogBox - m.help.FullHelpOneLine = fullHelpDialogBoxOneLine - m.help.FullHelp = fullHelpDialogBox - case focusControls: - m.help.ShortHelp = common.ShortHelpList - m.help.FullHelpOneLine = common.FullHelpListOneLine - m.help.FullHelp = common.FullHelpList - case focusRemarks: - if m.remarksEditor.Focused() { - m.help.ShortHelp = common.ShortHelpEditing - m.help.FullHelpOneLine = common.FullHelpEditingOneLine - m.help.FullHelp = common.FullHelpEditing - } else { - m.help.ShortHelp = shortHelpEditableDialogBox - m.help.FullHelpOneLine = fullHelpEditableDialogBoxOneLine - m.help.FullHelp = fullHelpEditableDialogBox - } - case focusDescription: - if m.descriptionEditor.Focused() { - m.help.ShortHelp = common.ShortHelpEditing - m.help.FullHelpOneLine = common.FullHelpEditingOneLine - m.help.FullHelp = common.FullHelpEditing - } else { - m.help.ShortHelp = shortHelpEditableDialogBox - m.help.FullHelpOneLine = fullHelpEditableDialogBoxOneLine - m.help.FullHelp = fullHelpEditableDialogBox - } - case focusValidations: - m.help.ShortHelp = shortHelpValidations - m.help.FullHelpOneLine = fullHelpValidationsOneLine - m.help.FullHelp = fullHelpValidations - default: - m.help.ShortHelp = shortHelpNoFocus - m.help.FullHelpOneLine = fullHelpNoFocusOneLine - m.help.FullHelp = fullHelpNoFocus - } -} diff --git a/src/internal/tui/component/testdata/TestDetailValidationViewComponentDefinitionModel.golden b/src/internal/tui/component/testdata/TestDetailValidationViewComponentDefinitionModel.golden index 82ca8741..5bbbcf59 100644 --- a/src/internal/tui/component/testdata/TestDetailValidationViewComponentDefinitionModel.golden +++ b/src/internal/tui/component/testdata/TestDetailValidationViewComponentDefinitionModel.golden @@ -50,8 +50,8 @@ ║ name: example-pod ║ ║ group: "" ║ ║ version: v1 ║ + ║ resource: pods ║ ║ ║ ║ ║ ║ ↑/k scroll up • ↓/j scroll down • esc cancel ║ - ║ ║ ╚════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝ \ No newline at end of file diff --git a/src/internal/tui/model.go b/src/internal/tui/model.go index c33441db..810ef9e7 100644 --- a/src/internal/tui/model.go +++ b/src/internal/tui/model.go @@ -1,7 +1,6 @@ package tui import ( - "encoding/json" "fmt" "os" "reflect" @@ -18,6 +17,11 @@ import ( "github.com/defenseunicorns/lula/src/pkg/common/oscal" ) +type SwitchTabMsg struct { + FromTab int + ToTab int +} + type model struct { keys common.Keys tabs []string @@ -63,7 +67,7 @@ func NewOSCALModel(modelMap map[string]*oscalTypes_1_1_2.OscalCompleteSchema, fi switch k { case "component": componentModel = component.NewComponentDefinitionModel(v.ComponentDefinition) - err := DeepCopy(v.ComponentDefinition, writtenComponentModel) + err := common.DeepCopy(v.ComponentDefinition, writtenComponentModel) if err != nil { common.PrintToLog("error creating deep copy of component model: %v", err) } @@ -118,7 +122,7 @@ func (m *model) writeOscalModel() tea.Msg { } common.PrintToLog("model saved") - _ = DeepCopy(m.componentModel.GetComponentDefinition(), m.writtenComponentModel) // G104 + _ = common.DeepCopy(m.componentModel.GetComponentDefinition(), m.writtenComponentModel) return common.SaveSuccessMsg{} } @@ -129,6 +133,7 @@ func (m model) Init() tea.Cmd { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd + fromTab := m.activeTab common.DumpToLog(msg) @@ -143,6 +148,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch k { case common.ContainsKey(k, m.keys.ModelRight.Keys()): m.activeTab = (m.activeTab + 1) % len(m.tabs) + return m, func() tea.Msg { + return SwitchTabMsg{ + FromTab: fromTab, + ToTab: m.activeTab, + } + } case common.ContainsKey(k, m.keys.ModelLeft.Keys()): if m.activeTab == 0 { @@ -150,6 +161,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.activeTab = m.activeTab - 1 } + return m, func() tea.Msg { + return SwitchTabMsg{ + FromTab: fromTab, + ToTab: m.activeTab, + } + } case common.ContainsKey(k, m.keys.Confirm.Keys()): if m.closeModel.Open { @@ -217,20 +234,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, tea.Quit) } return m, tea.Sequence(cmds...) + + case SwitchTabMsg: + return m, m.openTab(msg.ToTab) } mdl, cmd := m.saveModel.Update(msg) m.saveModel = mdl.(common.SaveModel) cmds = append(cmds, cmd) - tabModel, cmd := m.loadTabModel(msg) - if tabModel != nil { - switch m.tabs[m.activeTab] { - case "ComponentDefinition": - m.componentModel = tabModel.(component.Model) - case "AssessmentResults": - m.assessmentResultsModel = tabModel.(assessmentresults.Model) - } + // Only run update methods on active tab + switch m.tabs[m.activeTab] { + case "ComponentDefinition": + mdl, cmd = m.componentModel.Update(msg) + m.componentModel = mdl.(component.Model) + cmds = append(cmds, cmd) + case "AssessmentResults": + mdl, cmd = m.assessmentResultsModel.Update(msg) + m.assessmentResultsModel = mdl.(assessmentresults.Model) cmds = append(cmds, cmd) } @@ -261,57 +282,46 @@ func (m model) mainView() string { gap := common.TabGap.Render(strings.Repeat(" ", max(0, m.width-lipgloss.Width(row)-2))) row = lipgloss.JoinHorizontal(lipgloss.Bottom, row, gap) - tabModel, _ := m.loadTabModel(nil) - if tabModel != nil { - body := lipgloss.NewStyle().PaddingTop(0).PaddingLeft(2).Render(tabModel.View()) - return fmt.Sprintf("%s\n%s", row, body) - } - - return row -} - -func (m model) closeAllTabs() { - m.catalogModel.Close() - m.profileModel.Close() - m.componentModel.Close() - m.systemSecurityPlanModel.Close() - m.assessmentPlanModel.Close() - m.assessmentResultsModel.Close() - m.planOfActionAndMilestones.Close() -} - -func (m model) loadTabModel(msg tea.Msg) (tea.Model, tea.Cmd) { - m.closeAllTabs() + content := "" switch m.tabs[m.activeTab] { case "ComponentDefinition": - m.componentModel.Open(m.height-common.TabOffset, m.width) - return m.componentModel.Update(msg) + content = m.componentModel.View() case "AssessmentResults": - m.assessmentResultsModel.Open(m.height-common.TabOffset, m.width) - return m.assessmentResultsModel.Update(msg) - case "Catalog": - m.catalogModel.Open() - return m.catalogModel, nil - case "Profile": - m.profileModel.Open() - return m.profileModel, nil + content = m.assessmentResultsModel.View() case "SystemSecurityPlan": - m.systemSecurityPlanModel.Open() - return m.systemSecurityPlanModel, nil + content = m.systemSecurityPlanModel.View() case "AssessmentPlan": - m.assessmentPlanModel.Open() - return m.assessmentPlanModel, nil + content = m.assessmentPlanModel.View() case "PlanOfActionAndMilestones": - m.planOfActionAndMilestones.Open() - return m.planOfActionAndMilestones, nil + content = m.planOfActionAndMilestones.View() + case "Catalog": + content = m.catalogModel.View() + case "Profile": + content = m.profileModel.View() } - return nil, nil + + body := lipgloss.NewStyle().PaddingTop(0).PaddingLeft(2).Render(content) + return fmt.Sprintf("%s\n%s", row, body) } -func DeepCopy(src, dst interface{}) error { - data, err := json.Marshal(src) - if err != nil { - return err +func (m *model) openTab(tab int) func() tea.Msg { + switch m.tabs[tab] { + case "ComponentDefinition": + return func() tea.Msg { + return component.ModelOpenMsg{ + Height: m.height - common.TabOffset, + Width: m.width, + } + } + case "AssessmentResults": + return func() tea.Msg { + return assessmentresults.ModelOpenMsg{ + Height: m.height - common.TabOffset, + Width: m.width, + } + } + } + return func() tea.Msg { + return nil } - return json.Unmarshal(data, dst) } diff --git a/src/pkg/common/oscal/complete-schema.go b/src/pkg/common/oscal/complete-schema.go index 6edd03d7..810886a4 100644 --- a/src/pkg/common/oscal/complete-schema.go +++ b/src/pkg/common/oscal/complete-schema.go @@ -380,7 +380,6 @@ func sortBackMatter(backmatter *oscalTypes.BackMatter) { backmatter.Resources = &resources } } - return } // Merges two arrays of resources into a single array diff --git a/src/pkg/common/oscal/profile_test.go b/src/pkg/common/oscal/profile_test.go index 4b41cb4d..986c45f0 100644 --- a/src/pkg/common/oscal/profile_test.go +++ b/src/pkg/common/oscal/profile_test.go @@ -64,7 +64,7 @@ func TestMakeDeterministic(t *testing.T) { t.Helper() // Make deterministic - model.MakeDeterministic() + _ = model.MakeDeterministic() if model.Model == nil && expectNil { return diff --git a/src/test/unit/common/oscal/valid-assessment-results-added-finding.yaml b/src/test/unit/common/oscal/valid-assessment-results-added-finding.yaml new file mode 100644 index 00000000..930f0be8 --- /dev/null +++ b/src/test/unit/common/oscal/valid-assessment-results-added-finding.yaml @@ -0,0 +1,121 @@ +assessment-results: + import-ap: + href: "" + metadata: + last-modified: 2024-10-15T10:56:06.577123-04:00 + oscal-version: 1.1.2 + published: 2024-10-15T10:55:51.725572-04:00 + remarks: Assessment Results generated from Lula + title: '[System Name] Security Assessment Results (SAR)' + version: 0.0.1 + results: + - description: Assessment results for performing Validations with Lula version v0.9.1 + findings: + - description: | + Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 42C2FFDC-5F05-44DF-A67F-EEC8660AEFFD + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + related-observations: + - observation-uuid: 92cb3cad-bbcd-431a-aaa9-cd47275a3982 + target: + status: + state: not-satisfied + target-id: ID-1 + type: objective-id + title: 'Validation Result - Control: ID-1' + uuid: 4fe1724e-e63b-45cb-8e3f-2efd96823993 + - description: | + Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 86a0e8d9-0ce0-4304-afe7-4c000001e032 + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + related-observations: + - observation-uuid: e1ca2968-8652-41be-a19f-c32bc0b3086c + target: + status: + state: not-satisfied + target-id: ID-2 + type: objective-id + title: 'Validation Result - Control: ID-2' + uuid: a0af133c-a31d-4417-8491-5f1aad3c40cf + observations: + - collected: 2024-10-15T10:56:06.553304-04:00 + description: | + [TEST]: 88AB3470-B96B-4D7C-BC36-02BF9563C46C - lula-validation-1 + methods: + - TEST + relevant-evidence: + - description: | + Result: not-satisfied + remarks: | + validate.msg: Pod label foo is NOT bar + uuid: 92cb3cad-bbcd-431a-aaa9-cd47275a3982 + - collected: 2024-10-15T10:56:06.559086-04:00 + description: | + [TEST]: 01e21994-2cfc-45fb-ac84-d00f2e5912b0 - lula-validation-2 + methods: + - TEST + relevant-evidence: + - description: | + Result: not-satisfied + uuid: e1ca2968-8652-41be-a19f-c32bc0b3086c + props: + - name: threshold + ns: https://docs.lula.dev/oscal/ns + value: "false" + - name: target + ns: https://docs.lula.dev/oscal/ns + value: https://github.com/defenseunicorns/lula https://github.com/defenseunicorns/lula + reviewed-controls: + control-selections: + - description: Controls Assessed by Lula + include-controls: + - control-id: ID-1 + - control-id: ID-2 + description: Controls validated + remarks: Validation performed may indicate full or partial satisfaction + start: 2024-10-15T10:56:06.560163-04:00 + title: Lula Validation Result + uuid: ab06dbe8-d6a4-47fb-8476-f54809ca61e3 + - description: Assessment results for performing Validations with Lula version v0.9.1 + findings: + - description: | + Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 42C2FFDC-5F05-44DF-A67F-EEC8660AEFFD + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + related-observations: + - observation-uuid: 0e7fc8b3-b230-49f9-be7c-8f011536dfb4 + target: + status: + state: satisfied + target-id: ID-1 + type: objective-id + title: 'Validation Result - Control: ID-1' + uuid: 1389026d-039d-4e2f-97b8-169d75210dc2 + observations: + - collected: 2024-10-15T10:55:51.725223-04:00 + description: | + [TEST]: 88AB3470-B96B-4D7C-BC36-02BF9563C46C - lula-validation-1 + methods: + - TEST + relevant-evidence: + - description: | + Result: satisfied + remarks: | + validate.msg: Pod label foo=bar is satisfied + uuid: 0e7fc8b3-b230-49f9-be7c-8f011536dfb4 + props: + - name: threshold + ns: https://docs.lula.dev/oscal/ns + value: "false" + - name: target + ns: https://docs.lula.dev/oscal/ns + value: https://github.com/defenseunicorns/lula https://github.com/defenseunicorns/lula + reviewed-controls: + control-selections: + - description: Controls Assessed by Lula + include-controls: + - control-id: ID-1 + - control-id: ID-2 + description: Controls validated + remarks: Validation performed may indicate full or partial satisfaction + start: 2024-10-15T10:55:51.725567-04:00 + title: Lula Validation Result + uuid: 9e445ab0-360c-456f-888d-37643225c8a7 + uuid: a3d45141-abd1-4a25-82db-05fd3ccf9c53 diff --git a/src/test/unit/common/oscal/valid-assessment-results-multi.yaml b/src/test/unit/common/oscal/valid-assessment-results-multi.yaml new file mode 100644 index 00000000..a79483a7 --- /dev/null +++ b/src/test/unit/common/oscal/valid-assessment-results-multi.yaml @@ -0,0 +1,142 @@ +assessment-results: + import-ap: + href: "" + metadata: + last-modified: 2024-10-15T10:56:06.577123-04:00 + oscal-version: 1.1.2 + published: 2024-10-15T10:55:51.725572-04:00 + remarks: Assessment Results generated from Lula + title: '[System Name] Security Assessment Results (SAR)' + version: 0.0.1 + results: + - description: Assessment results for performing Validations with Lula version v0.9.1 + findings: + - description: | + Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 42C2FFDC-5F05-44DF-A67F-EEC8660AEFFD + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + related-observations: + - observation-uuid: 92cb3cad-bbcd-431a-aaa9-cd47275a3982 + target: + status: + state: not-satisfied + target-id: ID-1 + type: objective-id + title: 'Validation Result - Control: ID-1' + uuid: 4fe1724e-e63b-45cb-8e3f-2efd96823993 + - description: | + Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 86a0e8d9-0ce0-4304-afe7-4c000001e032 + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + related-observations: + - observation-uuid: e1ca2968-8652-41be-a19f-c32bc0b3086c + target: + status: + state: not-satisfied + target-id: ID-2 + type: objective-id + title: 'Validation Result - Control: ID-2' + uuid: a0af133c-a31d-4417-8491-5f1aad3c40cf + observations: + - collected: 2024-10-15T10:56:06.553304-04:00 + description: | + [TEST]: 88AB3470-B96B-4D7C-BC36-02BF9563C46C - lula-validation-1 + methods: + - TEST + relevant-evidence: + - description: | + Result: not-satisfied + remarks: | + validate.msg: Pod label foo is NOT bar + uuid: 92cb3cad-bbcd-431a-aaa9-cd47275a3982 + - collected: 2024-10-15T10:56:06.559086-04:00 + description: | + [TEST]: 01e21994-2cfc-45fb-ac84-d00f2e5912b0 - lula-validation-2 + methods: + - TEST + relevant-evidence: + - description: | + Result: not-satisfied + uuid: e1ca2968-8652-41be-a19f-c32bc0b3086c + props: + - name: threshold + ns: https://docs.lula.dev/oscal/ns + value: "false" + - name: target + ns: https://docs.lula.dev/oscal/ns + value: https://github.com/defenseunicorns/lula https://github.com/defenseunicorns/lula + reviewed-controls: + control-selections: + - description: Controls Assessed by Lula + include-controls: + - control-id: ID-1 + - control-id: ID-2 + description: Controls validated + remarks: Validation performed may indicate full or partial satisfaction + start: 2024-10-15T10:56:06.560163-04:00 + title: Lula Validation Result + uuid: ab06dbe8-d6a4-47fb-8476-f54809ca61e3 + - description: Assessment results for performing Validations with Lula version v0.9.1 + findings: + - description: | + Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 42C2FFDC-5F05-44DF-A67F-EEC8660AEFFD + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + related-observations: + - observation-uuid: 0e7fc8b3-b230-49f9-be7c-8f011536dfb4 + target: + status: + state: satisfied + target-id: ID-1 + type: objective-id + title: 'Validation Result - Control: ID-1' + uuid: 1389026d-039d-4e2f-97b8-169d75210dc2 + - description: | + Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 86a0e8d9-0ce0-4304-afe7-4c000001e032 + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + related-observations: + - observation-uuid: af060637-2899-4f26-ae9d-2c1bbbddc4b0 + target: + status: + state: satisfied + target-id: ID-2 + type: objective-id + title: 'Validation Result - Control: ID-2' + uuid: 0e34e5cb-8eb4-49e4-b1f3-e6836b1fe791 + observations: + - collected: 2024-10-15T10:55:51.721924-04:00 + description: | + [TEST]: 01e21994-2cfc-45fb-ac84-d00f2e5912b0 - lula-validation-2 + methods: + - TEST + relevant-evidence: + - description: | + Result: satisfied + uuid: af060637-2899-4f26-ae9d-2c1bbbddc4b0 + - collected: 2024-10-15T10:55:51.725223-04:00 + description: | + [TEST]: 88AB3470-B96B-4D7C-BC36-02BF9563C46C - lula-validation-1 + methods: + - TEST + relevant-evidence: + - description: | + Result: satisfied + remarks: | + validate.msg: Pod label foo=bar is satisfied + uuid: 0e7fc8b3-b230-49f9-be7c-8f011536dfb4 + props: + - name: threshold + ns: https://docs.lula.dev/oscal/ns + value: "false" + - name: target + ns: https://docs.lula.dev/oscal/ns + value: https://github.com/defenseunicorns/lula https://github.com/defenseunicorns/lula + reviewed-controls: + control-selections: + - description: Controls Assessed by Lula + include-controls: + - control-id: ID-1 + - control-id: ID-2 + description: Controls validated + remarks: Validation performed may indicate full or partial satisfaction + start: 2024-10-15T10:55:51.725567-04:00 + title: Lula Validation Result + uuid: 9e445ab0-360c-456f-888d-37643225c8a7 + uuid: a3d45141-abd1-4a25-82db-05fd3ccf9c53 diff --git a/src/test/unit/common/oscal/valid-assessment-results-removed-finding.yaml b/src/test/unit/common/oscal/valid-assessment-results-removed-finding.yaml new file mode 100644 index 00000000..eff68383 --- /dev/null +++ b/src/test/unit/common/oscal/valid-assessment-results-removed-finding.yaml @@ -0,0 +1,121 @@ +assessment-results: + import-ap: + href: "" + metadata: + last-modified: 2024-10-15T10:56:06.577123-04:00 + oscal-version: 1.1.2 + published: 2024-10-15T10:55:51.725572-04:00 + remarks: Assessment Results generated from Lula + title: '[System Name] Security Assessment Results (SAR)' + version: 0.0.1 + results: + - description: Assessment results for performing Validations with Lula version v0.9.1 + findings: + - description: | + Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 42C2FFDC-5F05-44DF-A67F-EEC8660AEFFD + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + related-observations: + - observation-uuid: 92cb3cad-bbcd-431a-aaa9-cd47275a3982 + target: + status: + state: not-satisfied + target-id: ID-1 + type: objective-id + title: 'Validation Result - Control: ID-1' + uuid: 4fe1724e-e63b-45cb-8e3f-2efd96823993 + observations: + - collected: 2024-10-15T10:56:06.553304-04:00 + description: | + [TEST]: 88AB3470-B96B-4D7C-BC36-02BF9563C46C - lula-validation-1 + methods: + - TEST + relevant-evidence: + - description: | + Result: not-satisfied + remarks: | + validate.msg: Pod label foo is NOT bar + uuid: 92cb3cad-bbcd-431a-aaa9-cd47275a3982 + props: + - name: threshold + ns: https://docs.lula.dev/oscal/ns + value: "false" + - name: target + ns: https://docs.lula.dev/oscal/ns + value: https://github.com/defenseunicorns/lula https://github.com/defenseunicorns/lula + reviewed-controls: + control-selections: + - description: Controls Assessed by Lula + include-controls: + - control-id: ID-1 + - control-id: ID-2 + description: Controls validated + remarks: Validation performed may indicate full or partial satisfaction + start: 2024-10-15T10:56:06.560163-04:00 + title: Lula Validation Result + uuid: ab06dbe8-d6a4-47fb-8476-f54809ca61e3 + - description: Assessment results for performing Validations with Lula version v0.9.1 + findings: + - description: | + Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 42C2FFDC-5F05-44DF-A67F-EEC8660AEFFD + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + related-observations: + - observation-uuid: 0e7fc8b3-b230-49f9-be7c-8f011536dfb4 + target: + status: + state: satisfied + target-id: ID-1 + type: objective-id + title: 'Validation Result - Control: ID-1' + uuid: 1389026d-039d-4e2f-97b8-169d75210dc2 + - description: | + Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 86a0e8d9-0ce0-4304-afe7-4c000001e032 + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + related-observations: + - observation-uuid: af060637-2899-4f26-ae9d-2c1bbbddc4b0 + target: + status: + state: satisfied + target-id: ID-2 + type: objective-id + title: 'Validation Result - Control: ID-2' + uuid: 0e34e5cb-8eb4-49e4-b1f3-e6836b1fe791 + observations: + - collected: 2024-10-15T10:55:51.721924-04:00 + description: | + [TEST]: 01e21994-2cfc-45fb-ac84-d00f2e5912b0 - lula-validation-2 + methods: + - TEST + relevant-evidence: + - description: | + Result: satisfied + uuid: af060637-2899-4f26-ae9d-2c1bbbddc4b0 + - collected: 2024-10-15T10:55:51.725223-04:00 + description: | + [TEST]: 88AB3470-B96B-4D7C-BC36-02BF9563C46C - lula-validation-1 + methods: + - TEST + relevant-evidence: + - description: | + Result: satisfied + remarks: | + validate.msg: Pod label foo=bar is satisfied + uuid: 0e7fc8b3-b230-49f9-be7c-8f011536dfb4 + props: + - name: threshold + ns: https://docs.lula.dev/oscal/ns + value: "false" + - name: target + ns: https://docs.lula.dev/oscal/ns + value: https://github.com/defenseunicorns/lula https://github.com/defenseunicorns/lula + reviewed-controls: + control-selections: + - description: Controls Assessed by Lula + include-controls: + - control-id: ID-1 + - control-id: ID-2 + description: Controls validated + remarks: Validation performed may indicate full or partial satisfaction + start: 2024-10-15T10:55:51.725567-04:00 + title: Lula Validation Result + uuid: 9e445ab0-360c-456f-888d-37643225c8a7 + uuid: a3d45141-abd1-4a25-82db-05fd3ccf9c53 diff --git a/src/test/unit/common/oscal/valid-assessment-results-removed-observation.yaml b/src/test/unit/common/oscal/valid-assessment-results-removed-observation.yaml new file mode 100644 index 00000000..195ce006 --- /dev/null +++ b/src/test/unit/common/oscal/valid-assessment-results-removed-observation.yaml @@ -0,0 +1,108 @@ +assessment-results: + import-ap: + href: "" + metadata: + last-modified: 2024-10-15T10:56:06.577123-04:00 + oscal-version: 1.1.2 + published: 2024-10-15T10:55:51.725572-04:00 + remarks: Assessment Results generated from Lula + title: '[System Name] Security Assessment Results (SAR)' + version: 0.0.1 + results: + - description: Assessment results for performing Validations with Lula version v0.9.1 + findings: + - description: | + Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 42C2FFDC-5F05-44DF-A67F-EEC8660AEFFD + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + related-observations: + - observation-uuid: 92cb3cad-bbcd-431a-aaa9-cd47275a3982 + target: + status: + state: not-satisfied + target-id: ID-1 + type: objective-id + title: 'Validation Result - Control: ID-1' + uuid: 4fe1724e-e63b-45cb-8e3f-2efd96823993 + observations: + - collected: 2024-10-15T10:56:06.553304-04:00 + description: | + [TEST]: 88AB3470-B96B-4D7C-BC36-02BF9563C46C - lula-validation-1 + methods: + - TEST + relevant-evidence: + - description: | + Result: not-satisfied + remarks: | + validate.msg: Pod label foo is NOT bar + uuid: 92cb3cad-bbcd-431a-aaa9-cd47275a3982 + props: + - name: threshold + ns: https://docs.lula.dev/oscal/ns + value: "false" + - name: target + ns: https://docs.lula.dev/oscal/ns + value: https://github.com/defenseunicorns/lula https://github.com/defenseunicorns/lula + reviewed-controls: + control-selections: + - description: Controls Assessed by Lula + include-controls: + - control-id: ID-1 + description: Controls validated + remarks: Validation performed may indicate full or partial satisfaction + start: 2024-10-15T10:56:06.560163-04:00 + title: Lula Validation Result + uuid: ab06dbe8-d6a4-47fb-8476-f54809ca61e3 + - description: Assessment results for performing Validations with Lula version v0.9.1 + findings: + - description: | + Control Implementation: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A / Implemented Requirement: 42C2FFDC-5F05-44DF-A67F-EEC8660AEFFD + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + related-observations: + - observation-uuid: 0e7fc8b3-b230-49f9-be7c-8f011536dfb4 + - observation-uuid: af060637-2899-4f26-ae9d-2c1bbbddc4b0 + target: + status: + state: satisfied + target-id: ID-1 + type: objective-id + title: 'Validation Result - Control: ID-1' + uuid: 1389026d-039d-4e2f-97b8-169d75210dc2 + observations: + - collected: 2024-10-15T10:55:51.721924-04:00 + description: | + [TEST]: 01e21994-2cfc-45fb-ac84-d00f2e5912b0 - lula-validation-2 + methods: + - TEST + relevant-evidence: + - description: | + Result: satisfied + uuid: af060637-2899-4f26-ae9d-2c1bbbddc4b0 + - collected: 2024-10-15T10:55:51.725223-04:00 + description: | + [TEST]: 88AB3470-B96B-4D7C-BC36-02BF9563C46C - lula-validation-1 + methods: + - TEST + relevant-evidence: + - description: | + Result: satisfied + remarks: | + validate.msg: Pod label foo=bar is satisfied + uuid: 0e7fc8b3-b230-49f9-be7c-8f011536dfb4 + props: + - name: threshold + ns: https://docs.lula.dev/oscal/ns + value: "false" + - name: target + ns: https://docs.lula.dev/oscal/ns + value: https://github.com/defenseunicorns/lula https://github.com/defenseunicorns/lula + reviewed-controls: + control-selections: + - description: Controls Assessed by Lula + include-controls: + - control-id: ID-1 + description: Controls validated + remarks: Validation performed may indicate full or partial satisfaction + start: 2024-10-15T10:55:51.725567-04:00 + title: Lula Validation Result + uuid: 9e445ab0-360c-456f-888d-37643225c8a7 + uuid: a3d45141-abd1-4a25-82db-05fd3ccf9c53 diff --git a/src/test/unit/common/oscal/valid-assessment-results.yaml b/src/test/unit/common/oscal/valid-assessment-results.yaml index ec13c4c8..65ce5111 100644 --- a/src/test/unit/common/oscal/valid-assessment-results.yaml +++ b/src/test/unit/common/oscal/valid-assessment-results.yaml @@ -18,7 +18,7 @@ assessment-results: - observation-uuid: 32c1b7c8-2e64-4786-b589-2d82587d4157 target: status: - state: not-satisfied + state: satisfied target-id: ID-1 type: objective-id title: 'Validation Result - Control: ID-1' @@ -31,9 +31,9 @@ assessment-results: - TEST relevant-evidence: - description: | - Result: not-satisfied + Result: satisfied remarks: | - Error running validation: domain GetResources error: configmaps "configmap-json" not found + validate.msg: Pod label foo=bar is satisfied uuid: 32c1b7c8-2e64-4786-b589-2d82587d4157 props: - name: threshold