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