a73x

internal/cloudinit/cloudinit_test.go

Ref:   Size: 4.9 KiB   History

package cloudinit

import (
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"gopkg.in/yaml.v3"
)

const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyForTests user@host"

// parse re-parses merged output so assertions test SEMANTICS, not text
// formatting (which reflows through the YAML round-trip by design).
func parse(t *testing.T, s string) map[string]any {
	t.Helper()
	require.True(t, strings.HasPrefix(s, "#cloud-config\n"), "merged output must keep the #cloud-config header, got: %q", s[:min(20, len(s))])
	var m map[string]any
	require.NoError(t, yaml.Unmarshal([]byte(s), &m))
	return m
}

func authKeys(t *testing.T, m map[string]any) []string {
	t.Helper()
	raw, ok := m["ssh_authorized_keys"]
	require.True(t, ok, "merged doc must have top-level ssh_authorized_keys")
	list, ok := raw.([]any)
	require.True(t, ok, "ssh_authorized_keys must be a list, got %T", raw)
	out := make([]string, len(list))
	for i, v := range list {
		out[i] = v.(string)
	}
	return out
}

func TestMergeAddsKeyWhenAbsent(t *testing.T) {
	in := "#cloud-config\npackages:\n  - htop\n"
	out, err := mergeSSHKey(in, key)
	require.NoError(t, err)
	m := parse(t, out)
	assert.Equal(t, []string{key}, authKeys(t, m))
	// Unrelated content survives.
	assert.Contains(t, m, "packages")
}

func TestMergeAppendsToExistingList(t *testing.T) {
	existing := "ssh-rsa AAAAexisting other@host"
	in := "#cloud-config\nssh_authorized_keys:\n  - " + existing + "\n"
	out, err := mergeSSHKey(in, key)
	require.NoError(t, err)
	assert.Equal(t, []string{existing, key}, authKeys(t, parse(t, out)))
}

func TestMergeIsIdempotentDedup(t *testing.T) {
	in := "#cloud-config\nssh_authorized_keys:\n  - " + key + "\n"
	out, err := mergeSSHKey(in, key)
	require.NoError(t, err)
	assert.Equal(t, []string{key}, authKeys(t, parse(t, out)), "an already-present key must not be duplicated")
}

func TestMergeNormalizesScalarKey(t *testing.T) {
	// cloud-init accepts ssh_authorized_keys as a single scalar; normalize to a
	// list and append rather than clobbering the user's existing key.
	existing := "ssh-rsa AAAAexisting other@host"
	in := "#cloud-config\nssh_authorized_keys: " + existing + "\n"
	out, err := mergeSSHKey(in, key)
	require.NoError(t, err)
	assert.Equal(t, []string{existing, key}, authKeys(t, parse(t, out)))
}

func TestMergeHeaderWithLeadingBlankLines(t *testing.T) {
	in := "\n\n#cloud-config\nruncmd:\n  - echo hi\n"
	out, err := mergeSSHKey(in, key)
	require.NoError(t, err)
	assert.Equal(t, []string{key}, authKeys(t, parse(t, out)))
}

func TestMergeRejectsNonCloudConfig(t *testing.T) {
	for _, in := range []string{
		"#!/bin/bash\necho hi\n",              // shell script user-data
		"## template: jinja\n#cloud-config\n", // jinja-templated (can't safely edit)
		"just some text",                      // not user-data at all
	} {
		_, err := mergeSSHKey(in, key)
		assert.Error(t, err, "must refuse to merge into non-cloud-config: %q", in)
	}
}

func TestMergeRejectsMalformedYAML(t *testing.T) {
	_, err := mergeSSHKey("#cloud-config\n  bad: : : indent\n\t- x\n", key)
	assert.Error(t, err)
}

func TestMergeRejectsSSHAuthorizedKeysWrongType(t *testing.T) {
	// A mapping where we expect a scalar/sequence — don't silently drop it.
	in := "#cloud-config\nssh_authorized_keys:\n  nested: value\n"
	_, err := mergeSSHKey(in, key)
	assert.Error(t, err)
}

func TestMergePreservesScalarFidelity(t *testing.T) {
	// The merge must NOT coerce unquoted scalar VALUES elsewhere in the doc:
	// an in-place node edit preserves them; a map[string]any round-trip mangles
	// permissions 0644->420, 1.10->1.1, dates, etc. This is the common path.
	in := "#cloud-config\n" +
		"write_files:\n" +
		"  - path: /etc/x\n" +
		"    permissions: 0644\n" +
		"version: 1.10\n" +
		"stamp: 2020-01-02\n"
	out, err := mergeSSHKey(in, key)
	require.NoError(t, err)
	assert.Contains(t, out, "0644", "octal permissions must survive verbatim, not become 420")
	assert.Contains(t, out, "1.10", "trailing zero must survive")
	assert.Contains(t, out, "2020-01-02", "date must not be normalized to RFC3339")
	// And the key still landed.
	assert.Contains(t, out, key)
}

func TestMergePreservesComments(t *testing.T) {
	in := "#cloud-config\n# keep me\npackages:\n  - htop # inline\n"
	out, err := mergeSSHKey(in, key)
	require.NoError(t, err)
	assert.Contains(t, out, "# keep me", "top-level comment must survive an in-place edit")
}

func TestMergeRejectsMultiDocYAML(t *testing.T) {
	// cloud-init rejects multi-document cloud-config; accepting-and-truncating
	// would mask an error the guest would raise. Reject it too.
	in := "#cloud-config\npackages: [htop]\n---\nruncmd:\n  - echo hi\n"
	_, err := mergeSSHKey(in, key)
	assert.Error(t, err)
}

func TestMergeEmptyBodyGetsKey(t *testing.T) {
	// A bare "#cloud-config" with no body is valid; the merge seeds the key.
	out, err := mergeSSHKey("#cloud-config\n", key)
	require.NoError(t, err)
	assert.Equal(t, []string{key}, authKeys(t, parse(t, out)))
}