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}