github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/command/views/json_view_test.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package views
     5  
     6  import (
     7  	"encoding/json"
     8  	"fmt"
     9  	"strings"
    10  	"testing"
    11  	"time"
    12  
    13  	"github.com/google/go-cmp/cmp"
    14  
    15  	"github.com/terramate-io/tf/addrs"
    16  	viewsjson "github.com/terramate-io/tf/command/views/json"
    17  	"github.com/terramate-io/tf/plans"
    18  	"github.com/terramate-io/tf/terminal"
    19  	"github.com/terramate-io/tf/tfdiags"
    20  	tfversion "github.com/terramate-io/tf/version"
    21  )
    22  
    23  // Calling NewJSONView should also always output a version message, which is a
    24  // convenient way to test that NewJSONView works.
    25  func TestNewJSONView(t *testing.T) {
    26  	streams, done := terminal.StreamsForTesting(t)
    27  	NewJSONView(NewView(streams))
    28  
    29  	version := tfversion.String()
    30  	want := []map[string]interface{}{
    31  		{
    32  			"@level":    "info",
    33  			"@message":  fmt.Sprintf("Terraform %s", version),
    34  			"@module":   "terraform.ui",
    35  			"type":      "version",
    36  			"terraform": version,
    37  			"ui":        JSON_UI_VERSION,
    38  		},
    39  	}
    40  
    41  	testJSONViewOutputEqualsFull(t, done(t).Stdout(), want)
    42  }
    43  
    44  func TestJSONView_Log(t *testing.T) {
    45  	streams, done := terminal.StreamsForTesting(t)
    46  	jv := NewJSONView(NewView(streams))
    47  
    48  	jv.Log("hello, world")
    49  
    50  	want := []map[string]interface{}{
    51  		{
    52  			"@level":   "info",
    53  			"@message": "hello, world",
    54  			"@module":  "terraform.ui",
    55  			"type":     "log",
    56  		},
    57  	}
    58  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
    59  }
    60  
    61  // This test covers only the basics of JSON diagnostic rendering, as more
    62  // complex diagnostics are tested elsewhere.
    63  func TestJSONView_Diagnostics(t *testing.T) {
    64  	streams, done := terminal.StreamsForTesting(t)
    65  	jv := NewJSONView(NewView(streams))
    66  
    67  	var diags tfdiags.Diagnostics
    68  	diags = diags.Append(tfdiags.Sourceless(
    69  		tfdiags.Warning,
    70  		`Improper use of "less"`,
    71  		`You probably mean "10 buckets or fewer"`,
    72  	))
    73  	diags = diags.Append(tfdiags.Sourceless(
    74  		tfdiags.Error,
    75  		"Unusually stripey cat detected",
    76  		"Are you sure this random_pet isn't a cheetah?",
    77  	))
    78  
    79  	jv.Diagnostics(diags)
    80  
    81  	want := []map[string]interface{}{
    82  		{
    83  			"@level":   "warn",
    84  			"@message": `Warning: Improper use of "less"`,
    85  			"@module":  "terraform.ui",
    86  			"type":     "diagnostic",
    87  			"diagnostic": map[string]interface{}{
    88  				"severity": "warning",
    89  				"summary":  `Improper use of "less"`,
    90  				"detail":   `You probably mean "10 buckets or fewer"`,
    91  			},
    92  		},
    93  		{
    94  			"@level":   "error",
    95  			"@message": "Error: Unusually stripey cat detected",
    96  			"@module":  "terraform.ui",
    97  			"type":     "diagnostic",
    98  			"diagnostic": map[string]interface{}{
    99  				"severity": "error",
   100  				"summary":  "Unusually stripey cat detected",
   101  				"detail":   "Are you sure this random_pet isn't a cheetah?",
   102  			},
   103  		},
   104  	}
   105  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   106  }
   107  
   108  func TestJSONView_DiagnosticsWithMetadata(t *testing.T) {
   109  	streams, done := terminal.StreamsForTesting(t)
   110  	jv := NewJSONView(NewView(streams))
   111  
   112  	var diags tfdiags.Diagnostics
   113  	diags = diags.Append(tfdiags.Sourceless(
   114  		tfdiags.Warning,
   115  		`Improper use of "less"`,
   116  		`You probably mean "10 buckets or fewer"`,
   117  	))
   118  	diags = diags.Append(tfdiags.Sourceless(
   119  		tfdiags.Error,
   120  		"Unusually stripey cat detected",
   121  		"Are you sure this random_pet isn't a cheetah?",
   122  	))
   123  
   124  	jv.Diagnostics(diags, "@meta", "extra_info")
   125  
   126  	want := []map[string]interface{}{
   127  		{
   128  			"@level":   "warn",
   129  			"@message": `Warning: Improper use of "less"`,
   130  			"@module":  "terraform.ui",
   131  			"type":     "diagnostic",
   132  			"diagnostic": map[string]interface{}{
   133  				"severity": "warning",
   134  				"summary":  `Improper use of "less"`,
   135  				"detail":   `You probably mean "10 buckets or fewer"`,
   136  			},
   137  			"@meta": "extra_info",
   138  		},
   139  		{
   140  			"@level":   "error",
   141  			"@message": "Error: Unusually stripey cat detected",
   142  			"@module":  "terraform.ui",
   143  			"type":     "diagnostic",
   144  			"diagnostic": map[string]interface{}{
   145  				"severity": "error",
   146  				"summary":  "Unusually stripey cat detected",
   147  				"detail":   "Are you sure this random_pet isn't a cheetah?",
   148  			},
   149  			"@meta": "extra_info",
   150  		},
   151  	}
   152  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   153  }
   154  
   155  func TestJSONView_PlannedChange(t *testing.T) {
   156  	streams, done := terminal.StreamsForTesting(t)
   157  	jv := NewJSONView(NewView(streams))
   158  
   159  	foo, diags := addrs.ParseModuleInstanceStr("module.foo")
   160  	if len(diags) > 0 {
   161  		t.Fatal(diags.Err())
   162  	}
   163  	managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"}
   164  	cs := &plans.ResourceInstanceChangeSrc{
   165  		Addr:        managed.Instance(addrs.StringKey("boop")).Absolute(foo),
   166  		PrevRunAddr: managed.Instance(addrs.StringKey("boop")).Absolute(foo),
   167  		ChangeSrc: plans.ChangeSrc{
   168  			Action: plans.Create,
   169  		},
   170  	}
   171  	jv.PlannedChange(viewsjson.NewResourceInstanceChange(cs))
   172  
   173  	want := []map[string]interface{}{
   174  		{
   175  			"@level":   "info",
   176  			"@message": `module.foo.test_instance.bar["boop"]: Plan to create`,
   177  			"@module":  "terraform.ui",
   178  			"type":     "planned_change",
   179  			"change": map[string]interface{}{
   180  				"action": "create",
   181  				"resource": map[string]interface{}{
   182  					"addr":             `module.foo.test_instance.bar["boop"]`,
   183  					"implied_provider": "test",
   184  					"module":           "module.foo",
   185  					"resource":         `test_instance.bar["boop"]`,
   186  					"resource_key":     "boop",
   187  					"resource_name":    "bar",
   188  					"resource_type":    "test_instance",
   189  				},
   190  			},
   191  		},
   192  	}
   193  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   194  }
   195  
   196  func TestJSONView_ResourceDrift(t *testing.T) {
   197  	streams, done := terminal.StreamsForTesting(t)
   198  	jv := NewJSONView(NewView(streams))
   199  
   200  	foo, diags := addrs.ParseModuleInstanceStr("module.foo")
   201  	if len(diags) > 0 {
   202  		t.Fatal(diags.Err())
   203  	}
   204  	managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"}
   205  	cs := &plans.ResourceInstanceChangeSrc{
   206  		Addr:        managed.Instance(addrs.StringKey("boop")).Absolute(foo),
   207  		PrevRunAddr: managed.Instance(addrs.StringKey("boop")).Absolute(foo),
   208  		ChangeSrc: plans.ChangeSrc{
   209  			Action: plans.Update,
   210  		},
   211  	}
   212  	jv.ResourceDrift(viewsjson.NewResourceInstanceChange(cs))
   213  
   214  	want := []map[string]interface{}{
   215  		{
   216  			"@level":   "info",
   217  			"@message": `module.foo.test_instance.bar["boop"]: Drift detected (update)`,
   218  			"@module":  "terraform.ui",
   219  			"type":     "resource_drift",
   220  			"change": map[string]interface{}{
   221  				"action": "update",
   222  				"resource": map[string]interface{}{
   223  					"addr":             `module.foo.test_instance.bar["boop"]`,
   224  					"implied_provider": "test",
   225  					"module":           "module.foo",
   226  					"resource":         `test_instance.bar["boop"]`,
   227  					"resource_key":     "boop",
   228  					"resource_name":    "bar",
   229  					"resource_type":    "test_instance",
   230  				},
   231  			},
   232  		},
   233  	}
   234  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   235  }
   236  
   237  func TestJSONView_ChangeSummary(t *testing.T) {
   238  	streams, done := terminal.StreamsForTesting(t)
   239  	jv := NewJSONView(NewView(streams))
   240  
   241  	jv.ChangeSummary(&viewsjson.ChangeSummary{
   242  		Add:       1,
   243  		Change:    2,
   244  		Remove:    3,
   245  		Operation: viewsjson.OperationApplied,
   246  	})
   247  
   248  	want := []map[string]interface{}{
   249  		{
   250  			"@level":   "info",
   251  			"@message": "Apply complete! Resources: 1 added, 2 changed, 3 destroyed.",
   252  			"@module":  "terraform.ui",
   253  			"type":     "change_summary",
   254  			"changes": map[string]interface{}{
   255  				"add":       float64(1),
   256  				"import":    float64(0),
   257  				"change":    float64(2),
   258  				"remove":    float64(3),
   259  				"operation": "apply",
   260  			},
   261  		},
   262  	}
   263  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   264  }
   265  
   266  func TestJSONView_ChangeSummaryWithImport(t *testing.T) {
   267  	streams, done := terminal.StreamsForTesting(t)
   268  	jv := NewJSONView(NewView(streams))
   269  
   270  	jv.ChangeSummary(&viewsjson.ChangeSummary{
   271  		Add:       1,
   272  		Change:    2,
   273  		Remove:    3,
   274  		Import:    1,
   275  		Operation: viewsjson.OperationApplied,
   276  	})
   277  
   278  	want := []map[string]interface{}{
   279  		{
   280  			"@level":   "info",
   281  			"@message": "Apply complete! Resources: 1 imported, 1 added, 2 changed, 3 destroyed.",
   282  			"@module":  "terraform.ui",
   283  			"type":     "change_summary",
   284  			"changes": map[string]interface{}{
   285  				"add":       float64(1),
   286  				"change":    float64(2),
   287  				"remove":    float64(3),
   288  				"import":    float64(1),
   289  				"operation": "apply",
   290  			},
   291  		},
   292  	}
   293  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   294  }
   295  
   296  func TestJSONView_Hook(t *testing.T) {
   297  	streams, done := terminal.StreamsForTesting(t)
   298  	jv := NewJSONView(NewView(streams))
   299  
   300  	foo, diags := addrs.ParseModuleInstanceStr("module.foo")
   301  	if len(diags) > 0 {
   302  		t.Fatal(diags.Err())
   303  	}
   304  	managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"}
   305  	addr := managed.Instance(addrs.StringKey("boop")).Absolute(foo)
   306  	hook := viewsjson.NewApplyComplete(addr, plans.Create, "id", "boop-beep", 34*time.Second)
   307  
   308  	jv.Hook(hook)
   309  
   310  	want := []map[string]interface{}{
   311  		{
   312  			"@level":   "info",
   313  			"@message": `module.foo.test_instance.bar["boop"]: Creation complete after 34s [id=boop-beep]`,
   314  			"@module":  "terraform.ui",
   315  			"type":     "apply_complete",
   316  			"hook": map[string]interface{}{
   317  				"resource": map[string]interface{}{
   318  					"addr":             `module.foo.test_instance.bar["boop"]`,
   319  					"implied_provider": "test",
   320  					"module":           "module.foo",
   321  					"resource":         `test_instance.bar["boop"]`,
   322  					"resource_key":     "boop",
   323  					"resource_name":    "bar",
   324  					"resource_type":    "test_instance",
   325  				},
   326  				"action":          "create",
   327  				"id_key":          "id",
   328  				"id_value":        "boop-beep",
   329  				"elapsed_seconds": float64(34),
   330  			},
   331  		},
   332  	}
   333  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   334  }
   335  
   336  func TestJSONView_Outputs(t *testing.T) {
   337  	streams, done := terminal.StreamsForTesting(t)
   338  	jv := NewJSONView(NewView(streams))
   339  
   340  	jv.Outputs(viewsjson.Outputs{
   341  		"boop_count": {
   342  			Sensitive: false,
   343  			Value:     json.RawMessage(`92`),
   344  			Type:      json.RawMessage(`"number"`),
   345  		},
   346  		"password": {
   347  			Sensitive: true,
   348  			Value:     json.RawMessage(`"horse-battery"`),
   349  			Type:      json.RawMessage(`"string"`),
   350  		},
   351  	})
   352  
   353  	want := []map[string]interface{}{
   354  		{
   355  			"@level":   "info",
   356  			"@message": "Outputs: 2",
   357  			"@module":  "terraform.ui",
   358  			"type":     "outputs",
   359  			"outputs": map[string]interface{}{
   360  				"boop_count": map[string]interface{}{
   361  					"sensitive": false,
   362  					"value":     float64(92),
   363  					"type":      "number",
   364  				},
   365  				"password": map[string]interface{}{
   366  					"sensitive": true,
   367  					"value":     "horse-battery",
   368  					"type":      "string",
   369  				},
   370  			},
   371  		},
   372  	}
   373  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   374  }
   375  
   376  // This helper function tests a possibly multi-line JSONView output string
   377  // against a slice of structs representing the desired log messages. It
   378  // verifies that the output of JSONView is in JSON log format, one message per
   379  // line.
   380  func testJSONViewOutputEqualsFull(t *testing.T, output string, want []map[string]interface{}, options ...cmp.Option) {
   381  	t.Helper()
   382  
   383  	// Remove final trailing newline
   384  	output = strings.TrimSuffix(output, "\n")
   385  
   386  	// Split log into lines, each of which should be a JSON log message
   387  	gotLines := strings.Split(output, "\n")
   388  
   389  	if len(gotLines) != len(want) {
   390  		t.Errorf("unexpected number of messages. got %d, want %d", len(gotLines), len(want))
   391  	}
   392  
   393  	// Unmarshal each line and compare to the expected value
   394  	for i := range gotLines {
   395  		var gotStruct map[string]interface{}
   396  		if i >= len(want) {
   397  			t.Error("reached end of want messages too soon")
   398  			break
   399  		}
   400  		wantStruct := want[i]
   401  
   402  		if err := json.Unmarshal([]byte(gotLines[i]), &gotStruct); err != nil {
   403  			t.Fatal(err)
   404  		}
   405  
   406  		if timestamp, ok := gotStruct["@timestamp"]; !ok {
   407  			t.Errorf("message has no timestamp: %#v", gotStruct)
   408  		} else {
   409  			// Remove the timestamp value from the struct to allow comparison
   410  			delete(gotStruct, "@timestamp")
   411  
   412  			// Verify the timestamp format
   413  			if _, err := time.Parse("2006-01-02T15:04:05.000000Z07:00", timestamp.(string)); err != nil {
   414  				t.Errorf("error parsing timestamp on line %d: %s", i, err)
   415  			}
   416  		}
   417  
   418  		if !cmp.Equal(wantStruct, gotStruct, options...) {
   419  			t.Errorf("unexpected output on line %d:\n%s", i, cmp.Diff(wantStruct, gotStruct))
   420  		}
   421  	}
   422  }
   423  
   424  // testJSONViewOutputEquals skips the first line of output, since it ought to
   425  // be a version message that we don't care about for most of our tests.
   426  func testJSONViewOutputEquals(t *testing.T, output string, want []map[string]interface{}, options ...cmp.Option) {
   427  	t.Helper()
   428  
   429  	// Remove up to the first newline
   430  	index := strings.Index(output, "\n")
   431  	if index >= 0 {
   432  		output = output[index+1:]
   433  	}
   434  	testJSONViewOutputEqualsFull(t, output, want, options...)
   435  }