]> ruderich.org/simon Gitweb - nsscash/nsscash.git/blob - main_test.go
47865651f8e5e8e0dc17f867091e52800dcc6daa
[nsscash/nsscash.git] / main_test.go
1 // Copyright (C) 2019  Simon Ruderich
2 //
3 // This program is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU Affero General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // This program is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU Affero General Public License for more details.
12 //
13 // You should have received a copy of the GNU Affero General Public License
14 // along with this program.  If not, see <https://www.gnu.org/licenses/>.
15
16 package main
17
18 import (
19         "crypto/sha1"
20         "encoding/hex"
21         "fmt"
22         "io/ioutil"
23         "log"
24         "net/http"
25         "net/http/httptest"
26         "os"
27         "reflect"
28         "runtime"
29         "strings"
30         "testing"
31         "time"
32 )
33
34 const (
35         configPath = "testdata/config.toml"
36         statePath  = "testdata/state.json"
37         passwdPath = "testdata/passwd.nsscash"
38         plainPath  = "testdata/plain"
39         groupPath  = "testdata/group.nsscash"
40 )
41
42 type args struct {
43         t       *testing.T
44         url     string
45         handler *func(http.ResponseWriter, *http.Request)
46 }
47
48 // mustNotExist verifies that all given paths don't exist in the file system.
49 func mustNotExist(t *testing.T, paths ...string) {
50         for _, p := range paths {
51                 f, err := os.Open(p)
52                 if err != nil {
53                         if !os.IsNotExist(err) {
54                                 t.Errorf("path %q: unexpected error: %v",
55                                         p, err)
56                         }
57                 } else {
58                         t.Errorf("path %q exists", p)
59                         f.Close()
60                 }
61         }
62 }
63
64 // mustHaveHash checks if the given path content has the given SHA-1 string
65 // (in hex).
66 func mustHaveHash(t *testing.T, path string, hash string) {
67         x, err := ioutil.ReadFile(path)
68         if err != nil {
69                 t.Fatal(err)
70         }
71
72         h := sha1.New()
73         h.Write(x)
74         y := hex.EncodeToString(h.Sum(nil))
75
76         if y != hash {
77                 t.Errorf("%q has unexpected hash %q", path, y)
78         }
79 }
80
81 // mustBeErrorWithSubstring checks if the given error, represented as string,
82 // contains the given substring. This is somewhat ugly but the simplest way to
83 // check for proper errors.
84 func mustBeErrorWithSubstring(t *testing.T, err error, substring string) {
85         if err == nil {
86                 t.Errorf("err is nil")
87         } else if !strings.Contains(err.Error(), substring) {
88                 t.Errorf("err %q does not contain string %q", err, substring)
89         }
90 }
91
92 func mustWriteConfig(t *testing.T, config string) {
93         err := ioutil.WriteFile(configPath, []byte(config), 0644)
94         if err != nil {
95                 t.Fatal(err)
96         }
97 }
98
99 func mustWritePasswdConfig(t *testing.T, url string) {
100         mustWriteConfig(t, fmt.Sprintf(`
101 statepath = "%[1]s"
102
103 [[file]]
104 type = "passwd"
105 url = "%[2]s/passwd"
106 path = "%[3]s"
107 `, statePath, url, passwdPath))
108 }
109
110 func mustWriteGroupConfig(t *testing.T, url string) {
111         mustWriteConfig(t, fmt.Sprintf(`
112 statepath = "%[1]s"
113
114 [[file]]
115 type = "group"
116 url = "%[2]s/group"
117 path = "%[3]s"
118 `, statePath, url, groupPath))
119 }
120
121 // mustCreate creates a file, truncating it if it exists. It then changes the
122 // modification to be in the past.
123 func mustCreate(t *testing.T, path string) {
124         f, err := os.Create(path)
125         if err != nil {
126                 t.Fatal(err)
127         }
128         err = f.Close()
129         if err != nil {
130                 t.Fatal(err)
131         }
132
133         // Change modification time to the past to detect updates to the file
134         mustMakeOld(t, path)
135 }
136
137 // mustMakeOld change the modification time of all paths to be in the past.
138 func mustMakeOld(t *testing.T, paths ...string) {
139         old := time.Now().Add(-2 * time.Hour)
140         for _, p := range paths {
141                 err := os.Chtimes(p, old, old)
142                 if err != nil {
143                         t.Fatal(err)
144                 }
145         }
146 }
147
148 // mustMakeOld verifies that all paths have a modification time in the past,
149 // as set by mustMakeOld().
150 func mustBeOld(t *testing.T, paths ...string) {
151         for _, p := range paths {
152                 i, err := os.Stat(p)
153                 if err != nil {
154                         t.Fatal(err)
155                 }
156
157                 mtime := i.ModTime()
158                 now := time.Now()
159                 if now.Sub(mtime) < time.Hour {
160                         t.Errorf("%q was recently modified", p)
161                 }
162         }
163 }
164
165 // mustBeNew verifies that all paths have a modification time in the present.
166 func mustBeNew(t *testing.T, paths ...string) {
167         for _, p := range paths {
168                 i, err := os.Stat(p)
169                 if err != nil {
170                         t.Fatal(err)
171                 }
172
173                 mtime := i.ModTime()
174                 now := time.Now()
175                 if now.Sub(mtime) > time.Hour {
176                         t.Errorf("%q was not recently modified", p)
177                 }
178         }
179 }
180
181 func TestMainFetch(t *testing.T) {
182         // Suppress log messages
183         log.SetOutput(ioutil.Discard)
184         defer log.SetOutput(os.Stderr)
185
186         tests := []func(args){
187                 // Perform most tests with passwd for simplicity
188                 fetchPasswdCacheFileDoesNotExist,
189                 fetchPasswd404,
190                 fetchPasswdEmpty,
191                 fetchPasswdInvalid,
192                 fetchPasswdLimits,
193                 fetchPasswd,
194                 // Tests for plain and group
195                 fetchPlainEmpty,
196                 fetchPlain,
197                 fetchGroupEmpty,
198                 fetchGroupInvalid,
199                 fetchGroupLimits,
200                 fetchGroup,
201                 // Special tests
202                 fetchNoConfig,
203                 fetchStateCannotRead,
204                 fetchStateInvalid,
205                 fetchStateCannotWrite,
206                 fetchCannotDeploy,
207                 fetchSecondFetchFails,
208         }
209
210         cleanup := []string{
211                 configPath,
212                 statePath,
213                 passwdPath,
214                 plainPath,
215                 groupPath,
216         }
217
218         for _, f := range tests {
219                 // NOTE: This is not guaranteed to work according to reflect's
220                 // documentation but seems to work reliable for normal
221                 // functions.
222                 fn := runtime.FuncForPC(reflect.ValueOf(f).Pointer())
223                 name := fn.Name()
224                 name = name[strings.LastIndex(name, ".")+1:]
225
226                 t.Run(name, func(t *testing.T) {
227                         // Preparation & cleanup
228                         for _, p := range cleanup {
229                                 err := os.Remove(p)
230                                 if err != nil && !os.IsNotExist(err) {
231                                         t.Fatal(err)
232                                 }
233                                 // Remove the file at the end of this test
234                                 // run, if it was created
235                                 defer os.Remove(p)
236                         }
237
238                         var handler func(http.ResponseWriter, *http.Request)
239                         ts := httptest.NewServer(http.HandlerFunc(
240                                 func(w http.ResponseWriter, r *http.Request) {
241                                         handler(w, r)
242                                 }))
243                         defer ts.Close()
244
245                         f(args{
246                                 t:       t,
247                                 url:     ts.URL,
248                                 handler: &handler,
249                         })
250                 })
251         }
252 }
253
254 func fetchPasswdCacheFileDoesNotExist(a args) {
255         t := a.t
256         mustWritePasswdConfig(t, a.url)
257
258         err := mainFetch(configPath)
259         mustBeErrorWithSubstring(t, err,
260                 "file.path \""+passwdPath+"\" must exist")
261
262         mustNotExist(t, statePath, passwdPath, plainPath, groupPath)
263 }
264
265 func fetchPasswd404(a args) {
266         t := a.t
267         mustWritePasswdConfig(t, a.url)
268         mustCreate(t, passwdPath)
269
270         *a.handler = func(w http.ResponseWriter, r *http.Request) {
271                 // 404
272                 w.WriteHeader(http.StatusNotFound)
273         }
274
275         err := mainFetch(configPath)
276         mustBeErrorWithSubstring(t, err,
277                 "status code 404")
278
279         mustNotExist(t, statePath, plainPath, groupPath)
280         mustBeOld(a.t, passwdPath)
281 }
282
283 func fetchPasswdEmpty(a args) {
284         t := a.t
285         mustWritePasswdConfig(t, a.url)
286         mustCreate(t, passwdPath)
287
288         *a.handler = func(w http.ResponseWriter, r *http.Request) {
289                 // Empty response
290         }
291
292         err := mainFetch(configPath)
293         mustBeErrorWithSubstring(t, err,
294                 "refusing to use empty passwd file")
295
296         mustNotExist(t, statePath, plainPath, groupPath)
297         mustBeOld(t, passwdPath)
298 }
299
300 func fetchPasswdInvalid(a args) {
301         t := a.t
302         mustWritePasswdConfig(t, a.url)
303         mustCreate(t, passwdPath)
304
305         *a.handler = func(w http.ResponseWriter, r *http.Request) {
306                 if r.URL.Path != "/passwd" {
307                         return
308                 }
309
310                 fmt.Fprintln(w, "root:x:invalid:0:root:/root:/bin/bash")
311         }
312
313         err := mainFetch(configPath)
314         mustBeErrorWithSubstring(t, err,
315                 "invalid uid in line")
316
317         mustNotExist(t, statePath, plainPath, groupPath)
318         mustBeOld(t, passwdPath)
319 }
320
321 func fetchPasswdLimits(a args) {
322         t := a.t
323         mustWritePasswdConfig(t, a.url)
324         mustCreate(t, passwdPath)
325
326         *a.handler = func(w http.ResponseWriter, r *http.Request) {
327                 if r.URL.Path != "/passwd" {
328                         return
329                 }
330
331                 fmt.Fprint(w, "root:x:0:0:root:/root:/bin/bash")
332                 for i := 0; i < 65536; i++ {
333                         fmt.Fprint(w, "x")
334                 }
335                 fmt.Fprint(w, "\n")
336         }
337
338         err := mainFetch(configPath)
339         mustBeErrorWithSubstring(t, err,
340                 "passwd too large to serialize")
341
342         mustNotExist(t, statePath, plainPath, groupPath)
343         mustBeOld(t, passwdPath)
344 }
345
346 func fetchPasswd(a args) {
347         t := a.t
348         mustWritePasswdConfig(t, a.url)
349         mustCreate(t, passwdPath)
350         mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
351
352         t.Log("First fetch, write files")
353
354         *a.handler = func(w http.ResponseWriter, r *http.Request) {
355                 if r.URL.Path != "/passwd" {
356                         return
357                 }
358
359                 // No "Last-Modified" header
360                 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
361                 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
362         }
363
364         err := mainFetch(configPath)
365         if err != nil {
366                 t.Error(err)
367         }
368
369         mustNotExist(t, plainPath, groupPath)
370         mustBeNew(t, passwdPath, statePath)
371         // The actual content of passwdPath is verified by the NSS tests
372         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
373
374         t.Log("Fetch again, no support for Last-Modified")
375
376         mustMakeOld(t, passwdPath, statePath)
377
378         err = mainFetch(configPath)
379         if err != nil {
380                 t.Error(err)
381         }
382
383         mustNotExist(t, plainPath, groupPath)
384         mustBeNew(t, passwdPath, statePath)
385         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
386
387         t.Log("Fetch again, support for Last-Modified, but not retrieved yet")
388
389         mustMakeOld(t, passwdPath, statePath)
390
391         lastChange := time.Now()
392         *a.handler = func(w http.ResponseWriter, r *http.Request) {
393                 if r.URL.Path != "/passwd" {
394                         return
395                 }
396
397                 modified := r.Header.Get("If-Modified-Since")
398                 if modified != "" {
399                         x, err := http.ParseTime(modified)
400                         if err != nil {
401                                 t.Fatalf("invalid If-Modified-Since %v",
402                                         modified)
403                         }
404                         if !x.Before(lastChange) {
405                                 w.WriteHeader(http.StatusNotModified)
406                                 return
407                         }
408                 }
409
410                 w.Header().Add("Last-Modified",
411                         lastChange.Format(http.TimeFormat))
412                 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
413                 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
414         }
415
416         err = mainFetch(configPath)
417         if err != nil {
418                 t.Error(err)
419         }
420
421         mustNotExist(t, plainPath, groupPath)
422         mustBeNew(t, passwdPath, statePath)
423         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
424
425         t.Log("Fetch again, support for Last-Modified")
426
427         mustMakeOld(t, passwdPath, statePath)
428
429         err = mainFetch(configPath)
430         if err != nil {
431                 t.Error(err)
432         }
433
434         mustNotExist(t, plainPath, groupPath)
435         mustBeOld(t, passwdPath)
436         mustBeNew(t, statePath)
437         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
438
439         t.Log("Corrupt local passwd cache, fetched again")
440
441         os.Chmod(passwdPath, 0644) // make writable again
442         mustCreate(t, passwdPath)
443         mustMakeOld(t, passwdPath, statePath)
444
445         err = mainFetch(configPath)
446         if err != nil {
447                 t.Error(err)
448         }
449
450         mustNotExist(t, plainPath, groupPath)
451         mustBeNew(t, passwdPath, statePath)
452         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
453 }
454
455 func fetchPlainEmpty(a args) {
456         t := a.t
457         mustWriteConfig(t, fmt.Sprintf(`
458 statepath = "%[1]s"
459
460 [[file]]
461 type = "plain"
462 url = "%[2]s/plain"
463 path = "%[3]s"
464 `, statePath, a.url, plainPath))
465         mustCreate(t, plainPath)
466
467         *a.handler = func(w http.ResponseWriter, r *http.Request) {
468                 // Empty response
469         }
470
471         err := mainFetch(configPath)
472         mustBeErrorWithSubstring(t, err,
473                 "refusing to use empty response")
474
475         mustNotExist(t, statePath, passwdPath, groupPath)
476         mustBeOld(t, plainPath)
477 }
478
479 func fetchPlain(a args) {
480         t := a.t
481         mustWriteConfig(t, fmt.Sprintf(`
482 statepath = "%[1]s"
483
484 [[file]]
485 type = "plain"
486 url = "%[2]s/plain"
487 path = "%[3]s"
488 `, statePath, a.url, plainPath))
489         mustCreate(t, plainPath)
490         mustHaveHash(t, plainPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
491
492         *a.handler = func(w http.ResponseWriter, r *http.Request) {
493                 if r.URL.Path != "/plain" {
494                         return
495                 }
496
497                 fmt.Fprintln(w, "some file")
498         }
499
500         err := mainFetch(configPath)
501         if err != nil {
502                 t.Error(err)
503         }
504
505         mustNotExist(t, passwdPath, groupPath)
506         mustBeNew(t, plainPath, statePath)
507         mustHaveHash(t, plainPath, "0e08b5e8c10abc3e455b75286ba4a1fbd56e18a5")
508
509         // Remaining functionality already tested in fetchPasswd()
510 }
511
512 func fetchGroupEmpty(a args) {
513         t := a.t
514         mustWriteGroupConfig(t, a.url)
515         mustCreate(t, groupPath)
516
517         *a.handler = func(w http.ResponseWriter, r *http.Request) {
518                 // Empty response
519         }
520
521         err := mainFetch(configPath)
522         mustBeErrorWithSubstring(t, err,
523                 "refusing to use empty group file")
524
525         mustNotExist(t, statePath, passwdPath, plainPath)
526         mustBeOld(t, groupPath)
527 }
528
529 func fetchGroupInvalid(a args) {
530         t := a.t
531         mustWriteGroupConfig(t, a.url)
532         mustCreate(t, groupPath)
533
534         *a.handler = func(w http.ResponseWriter, r *http.Request) {
535                 if r.URL.Path != "/group" {
536                         return
537                 }
538
539                 fmt.Fprintln(w, "root:x::")
540         }
541
542         err := mainFetch(configPath)
543         mustBeErrorWithSubstring(t, err,
544                 "invalid gid in line")
545
546         mustNotExist(t, statePath, passwdPath, plainPath)
547         mustBeOld(t, groupPath)
548 }
549
550 func fetchGroupLimits(a args) {
551         t := a.t
552         mustWriteGroupConfig(t, a.url)
553         mustCreate(t, groupPath)
554
555         *a.handler = func(w http.ResponseWriter, r *http.Request) {
556                 if r.URL.Path != "/group" {
557                         return
558                 }
559
560                 fmt.Fprint(w, "root:x:0:")
561                 for i := 0; i < 65536; i++ {
562                         fmt.Fprint(w, "x")
563                 }
564                 fmt.Fprint(w, "\n")
565         }
566
567         err := mainFetch(configPath)
568         mustBeErrorWithSubstring(t, err,
569                 "group too large to serialize")
570
571         mustNotExist(t, statePath, passwdPath, plainPath)
572         mustBeOld(t, groupPath)
573 }
574
575 func fetchGroup(a args) {
576         t := a.t
577         mustWriteGroupConfig(t, a.url)
578         mustCreate(t, groupPath)
579         mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
580
581         *a.handler = func(w http.ResponseWriter, r *http.Request) {
582                 if r.URL.Path != "/group" {
583                         return
584                 }
585
586                 fmt.Fprintln(w, "root:x:0:")
587                 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
588         }
589
590         err := mainFetch(configPath)
591         if err != nil {
592                 t.Error(err)
593         }
594
595         mustNotExist(t, passwdPath, plainPath)
596         mustBeNew(t, groupPath, statePath)
597         // The actual content of groupPath is verified by the NSS tests
598         mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
599
600         // Remaining functionality already tested in fetchPasswd()
601 }
602
603 func fetchNoConfig(a args) {
604         t := a.t
605
606         err := mainFetch(configPath)
607         mustBeErrorWithSubstring(t, err,
608                 configPath+": no such file or directory")
609
610         mustNotExist(t, configPath, statePath, passwdPath, plainPath, groupPath)
611 }
612
613 func fetchStateCannotRead(a args) {
614         t := a.t
615         mustWritePasswdConfig(t, a.url)
616
617         mustCreate(t, statePath)
618         err := os.Chmod(statePath, 0000)
619         if err != nil {
620                 t.Fatal(err)
621         }
622
623         err = mainFetch(configPath)
624         mustBeErrorWithSubstring(t, err,
625                 statePath+": permission denied")
626
627         mustNotExist(t, passwdPath, plainPath, groupPath)
628 }
629
630 func fetchStateInvalid(a args) {
631         t := a.t
632         mustWriteGroupConfig(t, a.url)
633         mustCreate(t, statePath)
634
635         err := mainFetch(configPath)
636         mustBeErrorWithSubstring(t, err,
637                 "unexpected end of JSON input")
638
639         mustNotExist(t, groupPath, passwdPath, plainPath)
640         mustBeOld(t, statePath)
641 }
642
643 func fetchStateCannotWrite(a args) {
644         t := a.t
645         mustWriteGroupConfig(t, a.url)
646         mustCreate(t, groupPath)
647         mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
648
649         *a.handler = func(w http.ResponseWriter, r *http.Request) {
650                 // To prevent mainFetch() from trying to update groupPath
651                 // which will also fail
652                 w.WriteHeader(http.StatusNotModified)
653         }
654
655         err := os.Chmod("testdata", 0500)
656         if err != nil {
657                 t.Fatal(err)
658         }
659         defer os.Chmod("testdata", 0755)
660
661         err = mainFetch(configPath)
662         mustBeErrorWithSubstring(t, err,
663                 "permission denied")
664
665         mustNotExist(t, statePath, passwdPath, plainPath)
666         mustBeOld(t, groupPath)
667 }
668
669 func fetchCannotDeploy(a args) {
670         t := a.t
671         mustWriteGroupConfig(t, a.url)
672         mustCreate(t, groupPath)
673         mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
674
675         *a.handler = func(w http.ResponseWriter, r *http.Request) {
676                 if r.URL.Path != "/group" {
677                         return
678                 }
679
680                 fmt.Fprintln(w, "root:x:0:")
681                 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
682         }
683
684         err := os.Chmod("testdata", 0500)
685         if err != nil {
686                 t.Fatal(err)
687         }
688         defer os.Chmod("testdata", 0755)
689
690         err = mainFetch(configPath)
691         mustBeErrorWithSubstring(t, err,
692                 "permission denied")
693
694         mustNotExist(t, statePath, passwdPath, plainPath)
695         mustBeOld(t, groupPath)
696 }
697
698 func fetchSecondFetchFails(a args) {
699         t := a.t
700         mustWriteConfig(t, fmt.Sprintf(`
701 statepath = "%[1]s"
702
703 [[file]]
704 type = "passwd"
705 url = "%[2]s/passwd"
706 path = "%[3]s"
707
708 [[file]]
709 type = "group"
710 url = "%[2]s/group"
711 path = "%[4]s"
712 `, statePath, a.url, passwdPath, groupPath))
713         mustCreate(t, passwdPath)
714         mustCreate(t, groupPath)
715         mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
716         mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
717
718         *a.handler = func(w http.ResponseWriter, r *http.Request) {
719                 if r.URL.Path == "/passwd" {
720                         fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
721                 }
722                 if r.URL.Path == "/group" {
723                         w.WriteHeader(http.StatusNotFound)
724                 }
725         }
726
727         err := mainFetch(configPath)
728         mustBeErrorWithSubstring(t, err,
729                 "status code 404")
730
731         mustNotExist(t, statePath, plainPath)
732         // Even though passwd was successfully fetched, no files were modified
733         // because the second fetch failed
734         mustBeOld(t, passwdPath, groupPath)
735 }