]> ruderich.org/simon Gitweb - nsscash/nsscash.git/blob - main_test.go
nsscash: main_test: add group 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         }
204
205         cleanup := []string{
206                 configPath,
207                 statePath,
208                 passwdPath,
209                 plainPath,
210                 groupPath,
211         }
212
213         for _, f := range tests {
214                 // NOTE: This is not guaranteed to work according to reflect's
215                 // documentation but seems to work reliable for normal
216                 // functions.
217                 fn := runtime.FuncForPC(reflect.ValueOf(f).Pointer())
218                 name := fn.Name()
219                 name = name[strings.LastIndex(name, ".")+1:]
220
221                 t.Run(name, func(t *testing.T) {
222                         // Preparation & cleanup
223                         for _, p := range cleanup {
224                                 err := os.Remove(p)
225                                 if err != nil && !os.IsNotExist(err) {
226                                         t.Fatal(err)
227                                 }
228                                 // Remove the file at the end of this test
229                                 // run, if it was created
230                                 defer os.Remove(p)
231                         }
232
233                         var handler func(http.ResponseWriter, *http.Request)
234                         ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
235                                 handler(w, r)
236                         }))
237                         defer ts.Close()
238
239                         f(args{
240                                 t:       t,
241                                 url:     ts.URL,
242                                 handler: &handler,
243                         })
244                 })
245         }
246 }
247
248 func fetchPasswdCacheFileDoesNotExist(a args) {
249         t := a.t
250         mustWritePasswdConfig(t, a.url)
251
252         err := mainFetch(configPath)
253         mustBeErrorWithSubstring(t, err,
254                 "file.path \""+passwdPath+"\" must exist")
255
256         mustNotExist(t, statePath, passwdPath, plainPath, groupPath)
257 }
258
259 func fetchPasswd404(a args) {
260         t := a.t
261         mustWritePasswdConfig(t, a.url)
262         mustCreate(t, passwdPath)
263
264         *a.handler = func(w http.ResponseWriter, r *http.Request) {
265                 // 404
266                 w.WriteHeader(http.StatusNotFound)
267         }
268
269         err := mainFetch(configPath)
270         mustBeErrorWithSubstring(t, err,
271                 "status code 404")
272
273         mustNotExist(t, statePath, plainPath, groupPath)
274         mustBeOld(a.t, passwdPath)
275 }
276
277 func fetchPasswdEmpty(a args) {
278         t := a.t
279         mustWritePasswdConfig(t, a.url)
280         mustCreate(t, passwdPath)
281
282         *a.handler = func(w http.ResponseWriter, r *http.Request) {
283                 // Empty response
284         }
285
286         err := mainFetch(configPath)
287         mustBeErrorWithSubstring(t, err,
288                 "refusing to use empty passwd file")
289
290         mustNotExist(t, statePath, plainPath, groupPath)
291         mustBeOld(t, passwdPath)
292 }
293
294 func fetchPasswdInvalid(a args) {
295         t := a.t
296         mustWritePasswdConfig(t, a.url)
297         mustCreate(t, passwdPath)
298
299         *a.handler = func(w http.ResponseWriter, r *http.Request) {
300                 if r.URL.Path != "/passwd" {
301                         return
302                 }
303
304                 fmt.Fprintln(w, "root:x:invalid:0:root:/root:/bin/bash")
305         }
306
307         err := mainFetch(configPath)
308         mustBeErrorWithSubstring(t, err,
309                 "invalid uid in line")
310
311         mustNotExist(t, statePath, plainPath, groupPath)
312         mustBeOld(t, passwdPath)
313 }
314
315 func fetchPasswdLimits(a args) {
316         t := a.t
317         mustWritePasswdConfig(t, a.url)
318         mustCreate(t, passwdPath)
319
320         *a.handler = func(w http.ResponseWriter, r *http.Request) {
321                 if r.URL.Path != "/passwd" {
322                         return
323                 }
324
325                 fmt.Fprint(w, "root:x:0:0:root:/root:/bin/bash")
326                 for i := 0; i < 65536; i++ {
327                         fmt.Fprint(w, "x")
328                 }
329                 fmt.Fprint(w, "\n")
330         }
331
332         err := mainFetch(configPath)
333         mustBeErrorWithSubstring(t, err,
334                 "passwd too large to serialize")
335
336         mustNotExist(t, statePath, plainPath, groupPath)
337         mustBeOld(t, passwdPath)
338 }
339
340 func fetchPasswd(a args) {
341         t := a.t
342         mustWritePasswdConfig(t, a.url)
343         mustCreate(t, passwdPath)
344         mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
345
346         t.Log("First fetch, write files")
347
348         *a.handler = func(w http.ResponseWriter, r *http.Request) {
349                 if r.URL.Path != "/passwd" {
350                         return
351                 }
352
353                 // No "Last-Modified" header
354                 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
355                 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
356         }
357
358         err := mainFetch(configPath)
359         if err != nil {
360                 t.Error(err)
361         }
362
363         mustNotExist(t, plainPath, groupPath)
364         mustBeNew(t, passwdPath, statePath)
365         // The actual content of passwdPath is verified by the NSS tests
366         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
367
368         t.Log("Fetch again, no support for Last-Modified")
369
370         mustMakeOld(t, passwdPath, statePath)
371
372         err = mainFetch(configPath)
373         if err != nil {
374                 t.Error(err)
375         }
376
377         mustNotExist(t, plainPath, groupPath)
378         mustBeNew(t, passwdPath, statePath)
379         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
380
381         t.Log("Fetch again, support for Last-Modified, but not retrieved yet")
382
383         mustMakeOld(t, passwdPath, statePath)
384
385         lastChange := time.Now()
386         *a.handler = func(w http.ResponseWriter, r *http.Request) {
387                 if r.URL.Path != "/passwd" {
388                         return
389                 }
390
391                 modified := r.Header.Get("If-Modified-Since")
392                 if modified != "" {
393                         x, err := http.ParseTime(modified)
394                         if err != nil {
395                                 t.Fatalf("invalid If-Modified-Since %v",
396                                         modified)
397                         }
398                         if !x.Before(lastChange) {
399                                 w.WriteHeader(http.StatusNotModified)
400                                 return
401                         }
402                 }
403
404                 w.Header().Add("Last-Modified",
405                         lastChange.Format(http.TimeFormat))
406                 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
407                 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
408         }
409
410         err = mainFetch(configPath)
411         if err != nil {
412                 t.Error(err)
413         }
414
415         mustNotExist(t, plainPath, groupPath)
416         mustBeNew(t, passwdPath, statePath)
417         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
418
419         t.Log("Fetch again, support for Last-Modified")
420
421         mustMakeOld(t, passwdPath, statePath)
422
423         err = mainFetch(configPath)
424         if err != nil {
425                 t.Error(err)
426         }
427
428         mustNotExist(t, plainPath, groupPath)
429         mustBeOld(t, passwdPath)
430         mustBeNew(t, statePath)
431         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
432
433         t.Log("Corrupt local passwd cache, fetched again")
434
435         os.Chmod(passwdPath, 0644) // make writable again
436         mustCreate(t, passwdPath)
437         mustMakeOld(t, passwdPath, statePath)
438
439         err = mainFetch(configPath)
440         if err != nil {
441                 t.Error(err)
442         }
443
444         mustNotExist(t, plainPath, groupPath)
445         mustBeNew(t, passwdPath, statePath)
446         mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
447 }
448
449 func fetchPlainEmpty(a args) {
450         t := a.t
451         mustWriteConfig(t, fmt.Sprintf(`
452 statepath = "%[1]s"
453
454 [[file]]
455 type = "plain"
456 url = "%[2]s/plain"
457 path = "%[3]s"
458 `, statePath, a.url, plainPath))
459         mustCreate(t, plainPath)
460
461         *a.handler = func(w http.ResponseWriter, r *http.Request) {
462                 // Empty response
463         }
464
465         err := mainFetch(configPath)
466         mustBeErrorWithSubstring(t, err,
467                 "refusing to use empty response")
468
469         mustNotExist(t, statePath, passwdPath, groupPath)
470         mustBeOld(t, plainPath)
471 }
472
473 func fetchPlain(a args) {
474         t := a.t
475         mustWriteConfig(t, fmt.Sprintf(`
476 statepath = "%[1]s"
477
478 [[file]]
479 type = "plain"
480 url = "%[2]s/plain"
481 path = "%[3]s"
482 `, statePath, a.url, plainPath))
483         mustCreate(t, plainPath)
484         mustHaveHash(t, plainPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
485
486         *a.handler = func(w http.ResponseWriter, r *http.Request) {
487                 if r.URL.Path != "/plain" {
488                         return
489                 }
490
491                 fmt.Fprintln(w, "some file")
492         }
493
494         err := mainFetch(configPath)
495         if err != nil {
496                 t.Error(err)
497         }
498
499         mustNotExist(t, passwdPath, groupPath)
500         mustBeNew(t, plainPath, statePath)
501         mustHaveHash(t, plainPath, "0e08b5e8c10abc3e455b75286ba4a1fbd56e18a5")
502
503         // Remaining functionality already tested in fetchPasswd()
504 }
505
506 func fetchGroupEmpty(a args) {
507         t := a.t
508         mustWriteGroupConfig(t, a.url)
509         mustCreate(t, groupPath)
510
511         *a.handler = func(w http.ResponseWriter, r *http.Request) {
512                 // Empty response
513         }
514
515         err := mainFetch(configPath)
516         mustBeErrorWithSubstring(t, err,
517                 "refusing to use empty group file")
518
519         mustNotExist(t, statePath, passwdPath, plainPath)
520         mustBeOld(t, groupPath)
521 }
522
523 func fetchGroupInvalid(a args) {
524         t := a.t
525         mustWriteGroupConfig(t, a.url)
526         mustCreate(t, groupPath)
527
528         *a.handler = func(w http.ResponseWriter, r *http.Request) {
529                 if r.URL.Path != "/group" {
530                         return
531                 }
532
533                 fmt.Fprintln(w, "root:x::")
534         }
535
536         err := mainFetch(configPath)
537         mustBeErrorWithSubstring(t, err,
538                 "invalid gid in line")
539
540         mustNotExist(t, statePath, passwdPath, plainPath)
541         mustBeOld(t, groupPath)
542 }
543
544 func fetchGroupLimits(a args) {
545         t := a.t
546         mustWriteGroupConfig(t, a.url)
547         mustCreate(t, groupPath)
548
549         *a.handler = func(w http.ResponseWriter, r *http.Request) {
550                 if r.URL.Path != "/group" {
551                         return
552                 }
553
554                 fmt.Fprint(w, "root:x:0:")
555                 for i := 0; i < 65536; i++ {
556                         fmt.Fprint(w, "x")
557                 }
558                 fmt.Fprint(w, "\n")
559         }
560
561         err := mainFetch(configPath)
562         mustBeErrorWithSubstring(t, err,
563                 "group too large to serialize")
564
565         mustNotExist(t, statePath, passwdPath, plainPath)
566         mustBeOld(t, groupPath)
567 }
568
569 func fetchGroup(a args) {
570         t := a.t
571         mustWriteGroupConfig(t, a.url)
572         mustCreate(t, groupPath)
573         mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
574
575         *a.handler = func(w http.ResponseWriter, r *http.Request) {
576                 if r.URL.Path != "/group" {
577                         return
578                 }
579
580                 fmt.Fprintln(w, "root:x:0:")
581                 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
582         }
583
584         err := mainFetch(configPath)
585         if err != nil {
586                 t.Error(err)
587         }
588
589         mustNotExist(t, passwdPath, plainPath)
590         mustBeNew(t, groupPath, statePath)
591         // The actual content of groupPath is verified by the NSS tests
592         mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
593
594         // Remaining functionality already tested in fetchPasswd()
595 }
596
597 func fetchNoConfig(a args) {
598         t := a.t
599
600         err := mainFetch(configPath)
601         mustBeErrorWithSubstring(t, err,
602                 configPath+": no such file or directory")
603
604         mustNotExist(t, configPath, statePath, passwdPath, plainPath, groupPath)
605 }