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