Skip to content

Commit f16cd60

Browse files
committed
feat(plugin_tests): add more coverage
1 parent 38edd4b commit f16cd60

1 file changed

Lines changed: 280 additions & 0 deletions

File tree

plugin/plugin_test.go

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
package plugin
22

33
import (
4+
"context"
45
"os"
56
"os/exec"
67
"path/filepath"
8+
"strings"
79
"testing"
10+
"time"
811

912
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
1014
)
1115

1216
const dummyPluginCode = `package main
@@ -50,3 +54,279 @@ func TestPluginIPC(t *testing.T) {
5054
assert.NoError(t, err)
5155
assert.Equal(t, "plain-as-day\n", string(plaintext))
5256
}
57+
58+
const flexiblePluginCode = `package main
59+
import (
60+
"encoding/json"
61+
"fmt"
62+
"os"
63+
"time"
64+
)
65+
func main() {
66+
if os.Getenv("MOCK_HANG") == "true" {
67+
time.Sleep(2 * time.Second)
68+
return
69+
}
70+
if os.Getenv("MOCK_INVALID_JSON") == "true" {
71+
fmt.Println("invalid-json-response")
72+
return
73+
}
74+
if os.Getenv("MOCK_ERROR_RESPONSE") == "true" {
75+
fmt.Println("{\"error\": \"plugin failed custom error\"}")
76+
return
77+
}
78+
if os.Getenv("MOCK_EMPTY_RESPONSE") == "true" {
79+
fmt.Println("{}")
80+
return
81+
}
82+
if os.Getenv("MOCK_STDERR_RESPONSE") == "true" {
83+
fmt.Fprintln(os.Stderr, "custom plugin stderr message")
84+
os.Exit(1)
85+
return
86+
}
87+
if os.Getenv("MOCK_STDERR_WITH_SUCCESS") == "true" {
88+
fmt.Fprintln(os.Stderr, "non-fatal warning message")
89+
}
90+
var req map[string]any
91+
if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
92+
fmt.Printf("{\"error\": \"decode error: %v\"}\n", err)
93+
return
94+
}
95+
action := req["action"].(string)
96+
if action == "encrypt" {
97+
ciphertext := req["plaintext"].(string)
98+
fmt.Printf("{\"ciphertext\": \"%s\"}\n", ciphertext)
99+
} else if action == "decrypt" {
100+
ciphertext := req["ciphertext"].(string)
101+
fmt.Printf("{\"plaintext\": \"%s\"}\n", ciphertext)
102+
}
103+
}
104+
`
105+
106+
func setupFlexiblePlugin(t *testing.T) *MasterKey {
107+
tmpDir := t.TempDir()
108+
pluginPath := filepath.Join(tmpDir, "sops-plugin-flexible")
109+
110+
srcFile := filepath.Join(tmpDir, "main.go")
111+
err := os.WriteFile(srcFile, []byte(flexiblePluginCode), 0o644)
112+
require.NoError(t, err)
113+
114+
err = exec.Command("go", "build", "-o", pluginPath, srcFile).Run()
115+
require.NoError(t, err, "failed to compile flexible plugin")
116+
117+
t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
118+
119+
return NewMasterKey("flexible", map[string]any{"conf": "val"}, "10s", "flexible")
120+
}
121+
122+
func TestMasterKeyBasicMethods(t *testing.T) {
123+
key := NewMasterKey("my-binary", map[string]any{"foo": "bar"}, "5s", "my-vault")
124+
125+
assert.Equal(t, KeyTypeIdentifier, key.TypeToIdentifier())
126+
assert.Equal(t, "plugin:my-binary", key.ToString())
127+
assert.Equal(t, "SOPS_PLUGIN_MY_VAULT_", key.GetEnvPrefix())
128+
129+
// Test default instance ID
130+
keyDefaultID := NewMasterKey("my-binary", nil, "", "")
131+
assert.Equal(t, "my-binary", keyDefaultID.InstanceID)
132+
assert.Equal(t, "SOPS_PLUGIN_MY_BINARY_", keyDefaultID.GetEnvPrefix())
133+
134+
// Test ToMap
135+
key.SetEncryptedDataKey([]byte("encrypted-payload"))
136+
assert.Equal(t, []byte("encrypted-payload"), key.EncryptedDataKey())
137+
138+
m := key.ToMap()
139+
assert.Equal(t, "my-binary", m["binary_name"])
140+
assert.Equal(t, map[string]any{"foo": "bar"}, m["config"])
141+
assert.Equal(t, "encrypted-payload", m["enc"])
142+
assert.NotEmpty(t, m["created_at"])
143+
144+
// Test NeedsRotation
145+
assert.False(t, key.NeedsRotation())
146+
}
147+
148+
func TestMasterKeyValidation(t *testing.T) {
149+
tests := []struct {
150+
name string
151+
binaryName string
152+
wantErr string
153+
}{
154+
{
155+
name: "valid name",
156+
binaryName: "valid-plugin_123",
157+
wantErr: "",
158+
},
159+
{
160+
name: "invalid char slash",
161+
binaryName: "plugin/path",
162+
wantErr: "invalid binary name: only alphanumeric, dashes, and underscores allowed",
163+
},
164+
{
165+
name: "invalid char space",
166+
binaryName: "plugin name",
167+
wantErr: "invalid binary name: only alphanumeric, dashes, and underscores allowed",
168+
},
169+
{
170+
name: "empty name",
171+
binaryName: "",
172+
wantErr: "invalid binary name: length must be between 1 and 128 characters",
173+
},
174+
{
175+
name: "too long name",
176+
binaryName: strings.Repeat("a", 129),
177+
wantErr: "invalid binary name: length must be between 1 and 128 characters",
178+
},
179+
}
180+
181+
for _, tt := range tests {
182+
t.Run(tt.name, func(t *testing.T) {
183+
key := NewMasterKey(tt.binaryName, nil, "", "")
184+
err := key.Encrypt([]byte("data"))
185+
if tt.wantErr == "" {
186+
if err != nil {
187+
assert.NotContains(t, err.Error(), "invalid binary name")
188+
}
189+
} else {
190+
assert.EqualError(t, err, tt.wantErr)
191+
}
192+
193+
_, err = key.Decrypt()
194+
if tt.wantErr == "" {
195+
if err != nil {
196+
assert.NotContains(t, err.Error(), "invalid binary name")
197+
}
198+
} else {
199+
assert.EqualError(t, err, tt.wantErr)
200+
}
201+
})
202+
}
203+
}
204+
205+
func TestMasterKeyEncryptIfNeeded(t *testing.T) {
206+
// If EncryptedKey is empty, EncryptIfNeeded should call Encrypt.
207+
// Since no plugin is present, it will fail.
208+
key := NewMasterKey("non-existent-plugin", nil, "", "")
209+
err := key.EncryptIfNeeded([]byte("data"))
210+
assert.Error(t, err)
211+
assert.Contains(t, err.Error(), "plugin executable not found")
212+
213+
// If EncryptedKey is not empty, EncryptIfNeeded should do nothing and return nil.
214+
key.EncryptedKey = "already-encrypted"
215+
err = key.EncryptIfNeeded([]byte("data"))
216+
assert.NoError(t, err)
217+
assert.Equal(t, "already-encrypted", key.EncryptedKey)
218+
}
219+
220+
func TestPluginHappyPath(t *testing.T) {
221+
key := setupFlexiblePlugin(t)
222+
223+
// Encrypt
224+
dataKey := []byte("hello-world")
225+
err := key.Encrypt(dataKey)
226+
assert.NoError(t, err)
227+
assert.NotEmpty(t, key.EncryptedKey)
228+
229+
// Decrypt
230+
plaintext, err := key.Decrypt()
231+
assert.NoError(t, err)
232+
assert.Equal(t, dataKey, plaintext)
233+
}
234+
235+
func TestPluginInvalidJSON(t *testing.T) {
236+
key := setupFlexiblePlugin(t)
237+
t.Setenv("MOCK_INVALID_JSON", "true")
238+
239+
err := key.Encrypt([]byte("test"))
240+
assert.Error(t, err)
241+
assert.Contains(t, err.Error(), "violated IPC contract (invalid JSON)")
242+
243+
_, err = key.Decrypt()
244+
assert.Error(t, err)
245+
assert.Contains(t, err.Error(), "violated IPC contract (invalid JSON)")
246+
}
247+
248+
func TestPluginErrorResponse(t *testing.T) {
249+
key := setupFlexiblePlugin(t)
250+
t.Setenv("MOCK_ERROR_RESPONSE", "true")
251+
252+
err := key.Encrypt([]byte("test"))
253+
assert.Error(t, err)
254+
assert.Contains(t, err.Error(), "plugin sops-plugin-flexible error: plugin failed custom error")
255+
256+
_, err = key.Decrypt()
257+
assert.Error(t, err)
258+
assert.Contains(t, err.Error(), "plugin sops-plugin-flexible error: plugin failed custom error")
259+
}
260+
261+
func TestPluginEmptyResponse(t *testing.T) {
262+
key := setupFlexiblePlugin(t)
263+
t.Setenv("MOCK_EMPTY_RESPONSE", "true")
264+
265+
err := key.Encrypt([]byte("test"))
266+
assert.Error(t, err)
267+
assert.Contains(t, err.Error(), "plugin did not return ciphertext")
268+
269+
_, err = key.Decrypt()
270+
assert.Error(t, err)
271+
assert.Contains(t, err.Error(), "plugin did not return plaintext")
272+
}
273+
274+
func TestPluginTimeout(t *testing.T) {
275+
key := setupFlexiblePlugin(t)
276+
key.Timeout = "100ms"
277+
t.Setenv("MOCK_HANG", "true")
278+
279+
err := key.Encrypt([]byte("test"))
280+
assert.Error(t, err)
281+
assert.Contains(t, err.Error(), "plugin execution timed out")
282+
283+
// Decrypt uses default timeout or context deadline. Let's pass a context with timeout.
284+
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
285+
defer cancel()
286+
_, err = key.DecryptContext(ctx)
287+
assert.Error(t, err)
288+
assert.Contains(t, err.Error(), "plugin execution timed out")
289+
}
290+
291+
func TestPluginNotFound(t *testing.T) {
292+
key := NewMasterKey("non-existent-plugin-12345", nil, "", "")
293+
err := key.Encrypt([]byte("test"))
294+
assert.Error(t, err)
295+
assert.Contains(t, err.Error(), "plugin executable not found")
296+
}
297+
298+
func TestPluginRequestMarshalError(t *testing.T) {
299+
// Put an unmarshallable channel in the plugin config
300+
key := NewMasterKey("flexible", map[string]any{"bad": make(chan int)}, "", "")
301+
err := key.Encrypt([]byte("test"))
302+
assert.Error(t, err)
303+
assert.Contains(t, err.Error(), "failed to marshal plugin request")
304+
}
305+
306+
func TestPluginStderrResponse(t *testing.T) {
307+
key := setupFlexiblePlugin(t)
308+
t.Setenv("MOCK_STDERR_RESPONSE", "true")
309+
310+
err := key.Encrypt([]byte("test"))
311+
assert.Error(t, err)
312+
assert.Contains(t, err.Error(), "plugin execution failed")
313+
assert.Contains(t, err.Error(), "custom plugin stderr message")
314+
315+
_, err = key.Decrypt()
316+
assert.Error(t, err)
317+
assert.Contains(t, err.Error(), "plugin execution failed")
318+
assert.Contains(t, err.Error(), "custom plugin stderr message")
319+
}
320+
321+
func TestPluginStderrWithSuccess(t *testing.T) {
322+
key := setupFlexiblePlugin(t)
323+
t.Setenv("MOCK_STDERR_WITH_SUCCESS", "true")
324+
325+
dataKey := []byte("hello-world")
326+
err := key.Encrypt(dataKey)
327+
assert.NoError(t, err)
328+
329+
plaintext, err := key.Decrypt()
330+
assert.NoError(t, err)
331+
assert.Equal(t, dataKey, plaintext)
332+
}

0 commit comments

Comments
 (0)