main 6af0a61

bootloader_handler_test.go RawHistoryFolder

1package handlers
2
3import (
4	"context"
5	"os"
6	"strings"
7	"testing"
8
9	"github.com/bnema/archup/internal/application/commands"
10	"github.com/bnema/archup/internal/domain/bootloader"
11	"github.com/bnema/archup/internal/domain/disk"
12	"github.com/bnema/archup/internal/domain/packages"
13	"github.com/bnema/archup/internal/domain/ports/mocks"
14	"go.uber.org/mock/gomock"
15)
16
17// limineTemplate is the minimal template content used in tests to avoid reading real files.
18// It mirrors the structure of limine.conf.template for the purpose of testing placeholder replacement.
19const limineTemplate = `timeout: {{TIMEOUT}}
20default_entry: 2
21quiet: yes
22interface_branding: {{BRANDING}}
23interface_branding_colour: cyan
24graphics: yes
25backdrop: 000000
26
27/+{{KERNEL}}
28comment: machine-id={{MACHINE_ID}}
29
30    //{{KERNEL}}
31    protocol: linux
32    path: boot():/vmlinuz-{{KERNEL}}
33    cmdline: {{KERNEL_PARAMS}}
34    module_path: boot():/initramfs-{{KERNEL}}.img
35{{FALLBACK_ENTRY}}
36    //Snapshots
37`
38
39// setupCommonMocks wires the expectations shared across bootloader handler tests.
40// Callers MUST register their own ReadFile and Stat expectations before calling configureLimine.
41func setupCommonMocks(mockFS *mocks.MockFileSystem, mockExec *mocks.MockCommandExecutor, mockChrExec *mocks.MockChrootExecutor, mockLogger *mocks.MockLogger) {
42	mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
43	mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
44	mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
45	mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
46	mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()
47	mockExec.EXPECT().Execute(gomock.Any(), "blkid", "-s", "UUID", "-o", "value", gomock.Any()).Return([]byte("uuid"), nil).AnyTimes()
48	mockExec.EXPECT().Execute(gomock.Any(), "cp", gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
49	mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
50	// efibootmgr and other multi-arg chroot commands: match any number of trailing args
51	mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), "efibootmgr", gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
52}
53
54func TestBootloaderHandler_Handle_Limine(t *testing.T) {
55	ctrl := gomock.NewController(t)
56	defer ctrl.Finish()
57
58	mockFS := mocks.NewMockFileSystem(ctrl)
59	mockExec := mocks.NewMockCommandExecutor(ctrl)
60	mockChrExec := mocks.NewMockChrootExecutor(ctrl)
61	mockLogger := mocks.NewMockLogger(ctrl)
62
63	setupCommonMocks(mockFS, mockExec, mockChrExec, mockLogger)
64	// ReadFile: mkinitcpio.conf and machine-id return defaults; template returns limineTemplate
65	mockFS.EXPECT().ReadFile(gomock.Any()).DoAndReturn(func(path string) ([]byte, error) {
66		if strings.HasSuffix(path, "limine.conf.template") {
67			return []byte(limineTemplate), nil
68		}
69		return []byte("HOOKS=(base)\n"), nil
70	}).AnyTimes()
71	// Stat: fallback image absent (os.ErrNotExist so the warn branch is not taken)
72	mockFS.EXPECT().Stat(gomock.Any()).Return(nil, os.ErrNotExist).AnyTimes()
73
74	handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
75
76	cmd := commands.InstallBootloaderCommand{
77		MountPoint:     "/mnt",
78		BootloaderType: bootloader.BootloaderTypeLimine,
79		TimeoutSeconds: 5,
80		Branding:       "ArchUp",
81		KernelVariant:  packages.KernelStable,
82		RootPartition:  "/dev/sda2",
83		EncryptionType: disk.EncryptionTypeNone,
84		EFIPartition:   "/dev/sda1",
85		TargetDisk:     "/dev/sda",
86	}
87
88	result, err := handler.Handle(context.Background(), cmd)
89
90	if err != nil {
91		t.Fatalf("expected no error, got %v", err)
92	}
93
94	if !result.Success {
95		t.Error("expected success")
96	}
97
98	if result.BootloaderType != "Limine" {
99		t.Errorf("expected bootloader type Limine, got %s", result.BootloaderType)
100	}
101
102	if result.Timeout != 5 {
103		t.Errorf("expected timeout 5, got %d", result.Timeout)
104	}
105}
106
107// TestConfigureLimine_FallbackAbsent verifies that when the fallback initramfs image does not
108// exist, the written limine.conf contains no "fallback" reference.
109func TestConfigureLimine_FallbackAbsent(t *testing.T) {
110	ctrl := gomock.NewController(t)
111	defer ctrl.Finish()
112
113	mockFS := mocks.NewMockFileSystem(ctrl)
114	mockExec := mocks.NewMockCommandExecutor(ctrl)
115	mockChrExec := mocks.NewMockChrootExecutor(ctrl)
116	mockLogger := mocks.NewMockLogger(ctrl)
117
118	mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
119
120	// blkid returns a UUID
121	mockExec.EXPECT().Execute(gomock.Any(), "blkid", "-s", "UUID", "-o", "value", gomock.Any()).
122		Return([]byte("test-uuid"), nil)
123
124	// Template lookup: Exists returns true for the template path
125	mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()
126
127	// ReadFile returns the limine template for the template file, and machine-id otherwise
128	mockFS.EXPECT().ReadFile(gomock.Any()).DoAndReturn(func(path string) ([]byte, error) {
129		if strings.HasSuffix(path, "limine.conf.template") {
130			return []byte(limineTemplate), nil
131		}
132		return []byte("abc123\n"), nil
133	}).AnyTimes()
134
135	// Stat: fallback image does NOT exist (os.ErrNotExist so the warn branch is not taken)
136	mockFS.EXPECT().Stat("/mnt/boot/initramfs-linux-fallback.img").Return(nil, os.ErrNotExist)
137
138	// Capture the written config
139	var writtenConfig string
140	mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
141		func(path string, data []byte, perm os.FileMode) error {
142			if strings.HasSuffix(path, "limine.conf") {
143				writtenConfig = string(data)
144			}
145			return nil
146		},
147	)
148
149	handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
150
151	cmd := commands.InstallBootloaderCommand{
152		MountPoint:     "/mnt",
153		BootloaderType: bootloader.BootloaderTypeLimine,
154		TimeoutSeconds: 5,
155		Branding:       "ArchUp",
156		KernelVariant:  packages.KernelStable,
157		RootPartition:  "/dev/sda2",
158		EncryptionType: disk.EncryptionTypeNone,
159	}
160
161	err := handler.configureLimine(context.Background(), cmd, "linux")
162	if err != nil {
163		t.Fatalf("expected no error, got %v", err)
164	}
165
166	if strings.Contains(writtenConfig, "fallback") {
167		t.Errorf("expected no 'fallback' in limine.conf when fallback image is absent, got:\n%s", writtenConfig)
168	}
169}
170
171// TestConfigureLimine_FallbackPresent verifies that when the fallback initramfs image exists,
172// the written limine.conf contains the full fallback stanza.
173func TestConfigureLimine_FallbackPresent(t *testing.T) {
174	ctrl := gomock.NewController(t)
175	defer ctrl.Finish()
176
177	mockFS := mocks.NewMockFileSystem(ctrl)
178	mockExec := mocks.NewMockCommandExecutor(ctrl)
179	mockChrExec := mocks.NewMockChrootExecutor(ctrl)
180	mockLogger := mocks.NewMockLogger(ctrl)
181
182	mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
183
184	// blkid returns a UUID
185	mockExec.EXPECT().Execute(gomock.Any(), "blkid", "-s", "UUID", "-o", "value", gomock.Any()).
186		Return([]byte("test-uuid"), nil)
187
188	// Template lookup
189	mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()
190
191	// ReadFile returns the limine template for the template file, and machine-id otherwise
192	mockFS.EXPECT().ReadFile(gomock.Any()).DoAndReturn(func(path string) ([]byte, error) {
193		if strings.HasSuffix(path, "limine.conf.template") {
194			return []byte(limineTemplate), nil
195		}
196		return []byte("abc123\n"), nil
197	}).AnyTimes()
198
199	// Stat: fallback image EXISTS (nil error)
200	mockFS.EXPECT().Stat("/mnt/boot/initramfs-linux-fallback.img").Return(nil, nil)
201
202	// Capture the written config
203	var writtenConfig string
204	mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
205		func(path string, data []byte, perm os.FileMode) error {
206			if strings.HasSuffix(path, "limine.conf") {
207				writtenConfig = string(data)
208			}
209			return nil
210		},
211	)
212
213	handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
214
215	cmd := commands.InstallBootloaderCommand{
216		MountPoint:     "/mnt",
217		BootloaderType: bootloader.BootloaderTypeLimine,
218		TimeoutSeconds: 5,
219		Branding:       "ArchUp",
220		KernelVariant:  packages.KernelStable,
221		RootPartition:  "/dev/sda2",
222		EncryptionType: disk.EncryptionTypeNone,
223	}
224
225	err := handler.configureLimine(context.Background(), cmd, "linux")
226	if err != nil {
227		t.Fatalf("expected no error, got %v", err)
228	}
229
230	if !strings.Contains(writtenConfig, "initramfs-linux-fallback.img") {
231		t.Errorf("expected 'initramfs-linux-fallback.img' in limine.conf when fallback image is present, got:\n%s", writtenConfig)
232	}
233}
234
235func TestBootloaderHandler_Handle_InvalidTimeout(t *testing.T) {
236	ctrl := gomock.NewController(t)
237	defer ctrl.Finish()
238
239	mockFS := mocks.NewMockFileSystem(ctrl)
240	mockExec := mocks.NewMockCommandExecutor(ctrl)
241	mockChrExec := mocks.NewMockChrootExecutor(ctrl)
242	mockLogger := mocks.NewMockLogger(ctrl)
243
244	mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
245	mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
246
247	handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
248
249	cmd := commands.InstallBootloaderCommand{
250		MountPoint:     "/mnt",
251		BootloaderType: bootloader.BootloaderTypeLimine,
252		TimeoutSeconds: 700, // Too large
253		Branding:       "ArchUp",
254		KernelVariant:  packages.KernelStable,
255		RootPartition:  "/dev/sda2",
256		EncryptionType: disk.EncryptionTypeNone,
257		EFIPartition:   "/dev/sda1",
258		TargetDisk:     "/dev/sda",
259	}
260
261	result, err := handler.Handle(context.Background(), cmd)
262
263	if err == nil {
264		t.Error("expected error for invalid timeout")
265	}
266
267	if result.Success {
268		t.Error("expected failure for invalid timeout")
269	}
270}
271
272func TestBootloaderHandler_Handle_InvalidBranding(t *testing.T) {
273	ctrl := gomock.NewController(t)
274	defer ctrl.Finish()
275
276	mockFS := mocks.NewMockFileSystem(ctrl)
277	mockExec := mocks.NewMockCommandExecutor(ctrl)
278	mockChrExec := mocks.NewMockChrootExecutor(ctrl)
279	mockLogger := mocks.NewMockLogger(ctrl)
280
281	mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
282	mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
283
284	handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
285
286	cmd := commands.InstallBootloaderCommand{
287		MountPoint:     "/mnt",
288		BootloaderType: bootloader.BootloaderTypeLimine,
289		TimeoutSeconds: 5,
290		Branding:       "", // Empty branding
291		KernelVariant:  packages.KernelStable,
292		RootPartition:  "/dev/sda2",
293		EncryptionType: disk.EncryptionTypeNone,
294		EFIPartition:   "/dev/sda1",
295		TargetDisk:     "/dev/sda",
296	}
297
298	result, err := handler.Handle(context.Background(), cmd)
299
300	if err == nil {
301		t.Error("expected error for empty branding")
302	}
303
304	if result.Success {
305		t.Error("expected failure for empty branding")
306	}
307}
308
309// TestConfigureMkinitcpio_WarnsWhenFallbackMissing verifies that configureMkinitcpio logs
310// a warning when mkinitcpio -P succeeds but the fallback initramfs image is not produced.
311func TestConfigureMkinitcpio_WarnsWhenFallbackMissing(t *testing.T) {
312	ctrl := gomock.NewController(t)
313	defer ctrl.Finish()
314
315	mockFS := mocks.NewMockFileSystem(ctrl)
316	mockExec := mocks.NewMockCommandExecutor(ctrl)
317	mockChrExec := mocks.NewMockChrootExecutor(ctrl)
318	mockLogger := mocks.NewMockLogger(ctrl)
319
320	mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
321	mockFS.EXPECT().ReadFile("/mnt/etc/mkinitcpio.conf").Return([]byte("MODULES=()\nHOOKS=(base udev)\n"), nil)
322	mockFS.EXPECT().WriteFile("/mnt/etc/mkinitcpio.conf", gomock.Any(), os.FileMode(0644)).Return(nil)
323	mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), "/mnt", "mkinitcpio", "-P").Return([]byte{}, nil)
324	mockFS.EXPECT().Stat("/mnt/boot/initramfs-linux-fallback.img").Return(nil, os.ErrNotExist)
325	mockLogger.EXPECT().Warn(
326		"Fallback initramfs was not generated; snapshot boot entries may not include fallback",
327		"kernel", "linux",
328		"path", "/mnt/boot/initramfs-linux-fallback.img",
329	)
330
331	handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
332
333	err := handler.configureMkinitcpio(context.Background(), "/mnt", disk.EncryptionTypeNone, "", "linux")
334	if err != nil {
335		t.Fatalf("expected no error, got %v", err)
336	}
337}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close