]> ruderich.org/simon Gitweb - nsscash/nsscash.git/blob - main_test.go
nsscash: main_test: refactor in preparation for HTTPS
[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         for _, f := range tests {
211                 runMainTest(t, f)
212         }
213 }
214
215 func runMainTest(t *testing.T, f func(args)) {
216         cleanup := []string{
217                 configPath,
218                 statePath,
219                 passwdPath,
220                 plainPath,
221                 groupPath,
222         }
223
224                 // NOTE: This is not guaranteed to work according to reflect's
225                 // documentation but seems to work reliable for normal
226                 // functions.
227                 fn := runtime.FuncForPC(reflect.ValueOf(f).Pointer())
228                 name := fn.Name()
229                 name = name[strings.LastIndex(name, ".")+1:]
230
231                 t.Run(name, func(t *testing.T) {
232                         // Preparation & cleanup
233                         for _, p := range cleanup {
234                                 err := os.Remove(p)
235                                 if err != nil && !os.IsNotExist(err) {
236                                         t.Fatal(err)
237                                 }
238                                 // Remove the file at the end of this test
239                                 // run, if it was created
240                                 defer os.Remove(p)
241                         }
242
243                         var handler func(http.ResponseWriter, *http.Request)
244                         ts := httptest.NewServer(http.HandlerFunc(
245                                 func(w http.ResponseWriter, r *http.Request) {
246                                         handler(w, r)
247                                 }))
248                         defer ts.Close()
249
250                         f(args{
251                                 t:       t,
252                                 url:     ts.URL,
253                                 handler: &handler,
254                         })
255                 })
256 }
257
258 func fetchPasswdCacheFileDoesNotExist(a args) {
259         t := a.t
260         mustWritePasswdConfig(t, a.url)
261
262         err := mainFetch(configPath)
263         mustBeErrorWithSubstring(t, err,
264                 "file.path \""+passwdPath+"\" must exist")
265
266         mustNotExist(t, statePath, passwdPath, plainPath, groupPath)
267 }
268
269 func fetchPasswd404(a args) {
270         t := a.t
271         mustWritePasswdConfig(t, a.url)
272         mustCreate(t, passwdPath)
273
274         *a.handler = func(w http.ResponseWriter, r *http.Request) {
275                 // 404
276                 w.WriteHeader(http.StatusNotFound)
277         }
278
279         err := mainFetch(configPath)
280         mustBeErrorWithSubstring(t, err,
281                 "status code 404")
282
283         mustNotExist(t, statePath, plainPath, groupPath)
284         mustBeOld(a.t, passwdPath)
285 }
286
287 func fetchPasswdEmpty(a args) {
288         t := a.t
289         mustWritePasswdConfig(t, a.url)
290         mustCreate(t, passwdPath)
291
292         *a.handler = func(w http.ResponseWriter, r *http.Request) {
293                 // Empty response
294         }
295
296         err := mainFetch(configPath)
297         mustBeErrorWithSubstring(t, err,
298                 "refusing to use empty passwd file")
299
300         mustNotExist(t, statePath, plainPath, groupPath)
301         mustBeOld(t, passwdPath)
302 }
303
304 func fetchPasswdInvalid(a args) {
305         t := a.t
306         mustWritePasswdConfig(t, a.url)
307         mustCreate(t, passwdPath)
308
309         *a.handler = func(w http.ResponseWriter, r *http.Request) {
310                 if r.URL.Path != "/passwd" {
311                         return
312                 }
313
314                 fmt.Fprintln(w, "root:x:invalid:0:root:/root:/bin/bash")
315         }
316
317         err := mainFetch(configPath)
318         mustBeErrorWithSubstring(t, err,
319                 "invalid uid in line")
320
321         mustNotExist(t, statePath, plainPath, groupPath)
322         mustBeOld(t, passwdPath)
323 }
324
325 func fetchPasswdLimits(a args) {
326         t := a.t
327         mustWritePasswdConfig(t, a.url)
328         mustCreate(t, passwdPath)
329
330         *a.handler = func(w http.ResponseWriter, r *http.Request) {
331                 if r.URL.Path != "/passwd" {
332                         return
333                 }
334
335                 fmt.Fprint(w, "root:x:0:0:root:/root:/bin/bash")
336                 for i := 0; i < 65536; i++ {
337                         fmt.Fprint(w, "x")
338                 }
339                 fmt.Fprint(w, "\n")
340         }
341
342         err := mainFetch(configPath)
343         mustBeErrorWithSubstring(t, err,
344                 "passwd too large to serialize")
345
346         mustNotExist(t, statePath, plainPath, groupPath)
347         mustBeOld(t, passwdPath)
348 }
349
350 func fetchPasswd(a args) {
351         t := a.t
352         mustWritePasswdConfig(t, a.url)
353         mustCreate(t, passwdPath)
354         mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
355
356         t.Log("First fetch, write files")
357
358         *a.handler = func(w http.ResponseWriter, r *http.Request) {
359                 if r.URL.Path != "/passwd" {
360                         return
361                 }
362
363                 // No "Last-Modified" header
364                 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
365                 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
366         }
367
368         err := mainFetch(configPath)
369         if err != nil {
370                 t.Error(err)
371         }
372
373         mustNotExist(t, plainPath, groupPath)
374         mustBeNew(t, passwdPath, statePath)
375         // The actual content of passwdPath is verified by the NSS tests
376         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
377
378         t.Log("Fetch again, no support for Last-Modified")
379
380         mustMakeOld(t, passwdPath, statePath)
381
382         err = mainFetch(configPath)
383         if err != nil {
384                 t.Error(err)
385         }
386
387         mustNotExist(t, plainPath, groupPath)
388         mustBeNew(t, passwdPath, statePath)
389         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
390
391         t.Log("Fetch again, support for Last-Modified, but not retrieved yet")
392
393         mustMakeOld(t, passwdPath, statePath)
394
395         lastChange := time.Now()
396         *a.handler = func(w http.ResponseWriter, r *http.Request) {
397                 if r.URL.Path != "/passwd" {
398                         return
399                 }
400
401                 modified := r.Header.Get("If-Modified-Since")
402                 if modified != "" {
403                         x, err := http.ParseTime(modified)
404                         if err != nil {
405                                 t.Fatalf("invalid If-Modified-Since %v",
406                                         modified)
407                         }
408                         if !x.Before(lastChange) {
409                                 w.WriteHeader(http.StatusNotModified)
410                                 return
411                         }
412                 }
413
414                 w.Header().Add("Last-Modified",
415                         lastChange.Format(http.TimeFormat))
416                 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
417                 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
418         }
419
420         err = mainFetch(configPath)
421         if err != nil {
422                 t.Error(err)
423         }
424
425         mustNotExist(t, plainPath, groupPath)
426         mustBeNew(t, passwdPath, statePath)
427         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
428
429         t.Log("Fetch again, support for Last-Modified")
430
431         mustMakeOld(t, passwdPath, statePath)
432
433         err = mainFetch(configPath)
434         if err != nil {
435                 t.Error(err)
436         }
437
438         mustNotExist(t, plainPath, groupPath)
439         mustBeOld(t, passwdPath)
440         mustBeNew(t, statePath)
441         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
442
443         t.Log("Corrupt local passwd cache, fetched again")
444
445         os.Chmod(passwdPath, 0644) // make writable again
446         mustCreate(t, passwdPath)
447         mustMakeOld(t, passwdPath, statePath)
448
449         err = mainFetch(configPath)
450         if err != nil {
451                 t.Error(err)
452         }
453
454         mustNotExist(t, plainPath, groupPath)
455         mustBeNew(t, passwdPath, statePath)
456         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
457 }
458
459 func fetchPlainEmpty(a args) {
460         t := a.t
461         mustWriteConfig(t, fmt.Sprintf(`
462 statepath = "%[1]s"
463
464 [[file]]
465 type = "plain"
466 url = "%[2]s/plain"
467 path = "%[3]s"
468 `, statePath, a.url, plainPath))
469         mustCreate(t, plainPath)
470
471         *a.handler = func(w http.ResponseWriter, r *http.Request) {
472                 // Empty response
473         }
474
475         err := mainFetch(configPath)
476         mustBeErrorWithSubstring(t, err,
477                 "refusing to use empty response")
478
479         mustNotExist(t, statePath, passwdPath, groupPath)
480         mustBeOld(t, plainPath)
481 }
482
483 func fetchPlain(a args) {
484         t := a.t
485         mustWriteConfig(t, fmt.Sprintf(`
486 statepath = "%[1]s"
487
488 [[file]]
489 type = "plain"
490 url = "%[2]s/plain"
491 path = "%[3]s"
492 `, statePath, a.url, plainPath))
493         mustCreate(t, plainPath)
494         mustHaveHash(t, plainPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
495
496         *a.handler = func(w http.ResponseWriter, r *http.Request) {
497                 if r.URL.Path != "/plain" {
498                         return
499                 }
500
501                 fmt.Fprintln(w, "some file")
502         }
503
504         err := mainFetch(configPath)
505         if err != nil {
506                 t.Error(err)
507         }
508
509         mustNotExist(t, passwdPath, groupPath)
510         mustBeNew(t, plainPath, statePath)
511         mustHaveHash(t, plainPath, "0e08b5e8c10abc3e455b75286ba4a1fbd56e18a5")
512
513         // Remaining functionality already tested in fetchPasswd()
514 }
515
516 func fetchGroupEmpty(a args) {
517         t := a.t
518         mustWriteGroupConfig(t, a.url)
519         mustCreate(t, groupPath)
520
521         *a.handler = func(w http.ResponseWriter, r *http.Request) {
522                 // Empty response
523         }
524
525         err := mainFetch(configPath)
526         mustBeErrorWithSubstring(t, err,
527                 "refusing to use empty group file")
528
529         mustNotExist(t, statePath, passwdPath, plainPath)
530         mustBeOld(t, groupPath)
531 }
532
533 func fetchGroupInvalid(a args) {
534         t := a.t
535         mustWriteGroupConfig(t, a.url)
536         mustCreate(t, groupPath)
537
538         *a.handler = func(w http.ResponseWriter, r *http.Request) {
539                 if r.URL.Path != "/group" {
540                         return
541                 }
542
543                 fmt.Fprintln(w, "root:x::")
544         }
545
546         err := mainFetch(configPath)
547         mustBeErrorWithSubstring(t, err,
548                 "invalid gid in line")
549
550         mustNotExist(t, statePath, passwdPath, plainPath)
551         mustBeOld(t, groupPath)
552 }
553
554 func fetchGroupLimits(a args) {
555         t := a.t
556         mustWriteGroupConfig(t, a.url)
557         mustCreate(t, groupPath)
558
559         *a.handler = func(w http.ResponseWriter, r *http.Request) {
560                 if r.URL.Path != "/group" {
561                         return
562                 }
563
564                 fmt.Fprint(w, "root:x:0:")
565                 for i := 0; i < 65536; i++ {
566                         fmt.Fprint(w, "x")
567                 }
568                 fmt.Fprint(w, "\n")
569         }
570
571         err := mainFetch(configPath)
572         mustBeErrorWithSubstring(t, err,
573                 "group too large to serialize")
574
575         mustNotExist(t, statePath, passwdPath, plainPath)
576         mustBeOld(t, groupPath)
577 }
578
579 func fetchGroup(a args) {
580         t := a.t
581         mustWriteGroupConfig(t, a.url)
582         mustCreate(t, groupPath)
583         mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
584
585         *a.handler = func(w http.ResponseWriter, r *http.Request) {
586                 if r.URL.Path != "/group" {
587                         return
588                 }
589
590                 fmt.Fprintln(w, "root:x:0:")
591                 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
592         }
593
594         err := mainFetch(configPath)
595         if err != nil {
596                 t.Error(err)
597         }
598
599         mustNotExist(t, passwdPath, plainPath)
600         mustBeNew(t, groupPath, statePath)
601         // The actual content of groupPath is verified by the NSS tests
602         mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
603
604         // Remaining functionality already tested in fetchPasswd()
605 }
606
607 func fetchNoConfig(a args) {
608         t := a.t
609
610         err := mainFetch(configPath)
611         mustBeErrorWithSubstring(t, err,
612                 configPath+": no such file or directory")
613
614         mustNotExist(t, configPath, statePath, passwdPath, plainPath, groupPath)
615 }
616
617 func fetchStateCannotRead(a args) {
618         t := a.t
619         mustWritePasswdConfig(t, a.url)
620
621         mustCreate(t, statePath)
622         err := os.Chmod(statePath, 0000)
623         if err != nil {
624                 t.Fatal(err)
625         }
626
627         err = mainFetch(configPath)
628         mustBeErrorWithSubstring(t, err,
629                 statePath+": permission denied")
630
631         mustNotExist(t, passwdPath, plainPath, groupPath)
632 }
633
634 func fetchStateInvalid(a args) {
635         t := a.t
636         mustWriteGroupConfig(t, a.url)
637         mustCreate(t, statePath)
638
639         err := mainFetch(configPath)
640         mustBeErrorWithSubstring(t, err,
641                 "unexpected end of JSON input")
642
643         mustNotExist(t, groupPath, passwdPath, plainPath)
644         mustBeOld(t, statePath)
645 }
646
647 func fetchStateCannotWrite(a args) {
648         t := a.t
649         mustWriteGroupConfig(t, a.url)
650         mustCreate(t, groupPath)
651         mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
652
653         *a.handler = func(w http.ResponseWriter, r *http.Request) {
654                 // To prevent mainFetch() from trying to update groupPath
655                 // which will also fail
656                 w.WriteHeader(http.StatusNotModified)
657         }
658
659         err := os.Chmod("testdata", 0500)
660         if err != nil {
661                 t.Fatal(err)
662         }
663         defer os.Chmod("testdata", 0755)
664
665         err = mainFetch(configPath)
666         mustBeErrorWithSubstring(t, err,
667                 "permission denied")
668
669         mustNotExist(t, statePath, passwdPath, plainPath)
670         mustBeOld(t, groupPath)
671 }
672
673 func fetchCannotDeploy(a args) {
674         t := a.t
675         mustWriteGroupConfig(t, a.url)
676         mustCreate(t, groupPath)
677         mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
678
679         *a.handler = func(w http.ResponseWriter, r *http.Request) {
680                 if r.URL.Path != "/group" {
681                         return
682                 }
683
684                 fmt.Fprintln(w, "root:x:0:")
685                 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
686         }
687
688         err := os.Chmod("testdata", 0500)
689         if err != nil {
690                 t.Fatal(err)
691         }
692         defer os.Chmod("testdata", 0755)
693
694         err = mainFetch(configPath)
695         mustBeErrorWithSubstring(t, err,
696                 "permission denied")
697
698         mustNotExist(t, statePath, passwdPath, plainPath)
699         mustBeOld(t, groupPath)
700 }
701
702 func fetchSecondFetchFails(a args) {
703         t := a.t
704         mustWriteConfig(t, fmt.Sprintf(`
705 statepath = "%[1]s"
706
707 [[file]]
708 type = "passwd"
709 url = "%[2]s/passwd"
710 path = "%[3]s"
711
712 [[file]]
713 type = "group"
714 url = "%[2]s/group"
715 path = "%[4]s"
716 `, statePath, a.url, passwdPath, groupPath))
717         mustCreate(t, passwdPath)
718         mustCreate(t, groupPath)
719         mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
720         mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
721
722         *a.handler = func(w http.ResponseWriter, r *http.Request) {
723                 if r.URL.Path == "/passwd" {
724                         fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
725                 }
726                 if r.URL.Path == "/group" {
727                         w.WriteHeader(http.StatusNotFound)
728                 }
729         }
730
731         err := mainFetch(configPath)
732         mustBeErrorWithSubstring(t, err,
733                 "status code 404")
734
735         mustNotExist(t, statePath, plainPath)
736         // Even though passwd was successfully fetched, no files were modified
737         // because the second fetch failed
738         mustBeOld(t, passwdPath, groupPath)
739 }