1 // Copyright (C) 2019 Simon Ruderich
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.
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.
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/>.
36 configPath = "testdata/config.toml"
37 statePath = "testdata/state.json"
38 passwdPath = "testdata/passwd.nsscash"
39 plainPath = "testdata/plain"
40 groupPath = "testdata/group.nsscash"
41 tlsCAPath = "testdata/ca.crt"
42 tlsCertPath = "testdata/server.crt"
43 tlsKeyPath = "testdata/server.key"
44 tlsCA2Path = "testdata/ca2.crt"
50 handler *func(http.ResponseWriter, *http.Request)
53 // mustNotExist verifies that all given paths don't exist in the file system.
54 func mustNotExist(t *testing.T, paths ...string) {
55 for _, p := range paths {
58 if !os.IsNotExist(err) {
59 t.Errorf("path %q: unexpected error: %v",
63 t.Errorf("path %q exists", p)
69 // mustHaveHash checks if the given path content has the given SHA-1 string
71 func mustHaveHash(t *testing.T, path string, hash string) {
72 x, err := ioutil.ReadFile(path)
79 y := hex.EncodeToString(h.Sum(nil))
82 t.Errorf("%q has unexpected hash %q", path, y)
86 // mustBeErrorWithSubstring checks if the given error, represented as string,
87 // contains the given substring. This is somewhat ugly but the simplest way to
88 // check for proper errors.
89 func mustBeErrorWithSubstring(t *testing.T, err error, substring string) {
91 t.Errorf("err is nil")
92 } else if !strings.Contains(err.Error(), substring) {
93 t.Errorf("err %q does not contain string %q", err, substring)
97 func mustWriteConfig(t *testing.T, config string) {
98 err := ioutil.WriteFile(configPath, []byte(config), 0644)
104 func mustWritePasswdConfig(t *testing.T, url string) {
105 mustWriteConfig(t, fmt.Sprintf(`
113 `, statePath, url, passwdPath, tlsCAPath))
116 func mustWriteGroupConfig(t *testing.T, url string) {
117 mustWriteConfig(t, fmt.Sprintf(`
125 `, statePath, url, groupPath, tlsCAPath))
128 // mustCreate creates a file, truncating it if it exists. It then changes the
129 // modification to be in the past.
130 func mustCreate(t *testing.T, path string) {
131 f, err := os.Create(path)
140 // Change modification time to the past to detect updates to the file
144 // mustMakeOld change the modification time of all paths to be in the past.
145 func mustMakeOld(t *testing.T, paths ...string) {
146 old := time.Now().Add(-2 * time.Hour)
147 for _, p := range paths {
148 err := os.Chtimes(p, old, old)
155 // mustMakeOld verifies that all paths have a modification time in the past,
156 // as set by mustMakeOld().
157 func mustBeOld(t *testing.T, paths ...string) {
158 for _, p := range paths {
166 if now.Sub(mtime) < time.Hour {
167 t.Errorf("%q was recently modified", p)
172 // mustBeNew verifies that all paths have a modification time in the present.
173 func mustBeNew(t *testing.T, paths ...string) {
174 for _, p := range paths {
182 if now.Sub(mtime) > time.Hour {
183 t.Errorf("%q was not recently modified", p)
188 func TestMainFetch(t *testing.T) {
189 // Suppress log messages
190 log.SetOutput(ioutil.Discard)
191 defer log.SetOutput(os.Stderr)
193 tests := []func(args){
194 // Perform most tests with passwd for simplicity
195 fetchPasswdCacheFileDoesNotExist,
201 // Tests for plain and group
210 fetchStateCannotRead,
212 fetchStateCannotWrite,
214 fetchSecondFetchFails,
219 for _, f := range tests {
220 runMainTest(t, f, nil)
225 tests = append(tests, fetchInvalidCA)
227 cert, err := tls.LoadX509KeyPair(tlsCertPath, tlsKeyPath)
232 Certificates: []tls.Certificate{cert},
235 for _, f := range tests {
236 runMainTest(t, f, tls)
240 func runMainTest(t *testing.T, f func(args), tls *tls.Config) {
249 // NOTE: This is not guaranteed to work according to reflect's
250 // documentation but seems to work reliable for normal functions.
251 fn := runtime.FuncForPC(reflect.ValueOf(f).Pointer())
253 name = name[strings.LastIndex(name, ".")+1:]
258 t.Run(name, func(t *testing.T) {
259 // Preparation & cleanup
260 for _, p := range cleanup {
262 if err != nil && !os.IsNotExist(err) {
265 // Remove the file at the end of this test run, if it
270 var handler func(http.ResponseWriter, *http.Request)
271 ts := httptest.NewUnstartedServer(http.HandlerFunc(
272 func(w http.ResponseWriter, r *http.Request) {
291 func fetchPasswdCacheFileDoesNotExist(a args) {
293 mustWritePasswdConfig(t, a.url)
295 err := mainFetch(configPath)
296 mustBeErrorWithSubstring(t, err,
297 "file.path \""+passwdPath+"\" must exist")
299 mustNotExist(t, statePath, passwdPath, plainPath, groupPath)
302 func fetchPasswd404(a args) {
304 mustWritePasswdConfig(t, a.url)
305 mustCreate(t, passwdPath)
307 *a.handler = func(w http.ResponseWriter, r *http.Request) {
309 w.WriteHeader(http.StatusNotFound)
312 err := mainFetch(configPath)
313 mustBeErrorWithSubstring(t, err,
316 mustNotExist(t, statePath, plainPath, groupPath)
317 mustBeOld(a.t, passwdPath)
320 func fetchPasswdEmpty(a args) {
322 mustWritePasswdConfig(t, a.url)
323 mustCreate(t, passwdPath)
325 *a.handler = func(w http.ResponseWriter, r *http.Request) {
329 err := mainFetch(configPath)
330 mustBeErrorWithSubstring(t, err,
331 "refusing to use empty passwd file")
333 mustNotExist(t, statePath, plainPath, groupPath)
334 mustBeOld(t, passwdPath)
337 func fetchPasswdInvalid(a args) {
339 mustWritePasswdConfig(t, a.url)
340 mustCreate(t, passwdPath)
342 *a.handler = func(w http.ResponseWriter, r *http.Request) {
343 if r.URL.Path != "/passwd" {
347 fmt.Fprintln(w, "root:x:invalid:0:root:/root:/bin/bash")
350 err := mainFetch(configPath)
351 mustBeErrorWithSubstring(t, err,
352 "invalid uid in line")
354 mustNotExist(t, statePath, plainPath, groupPath)
355 mustBeOld(t, passwdPath)
358 func fetchPasswdLimits(a args) {
360 mustWritePasswdConfig(t, a.url)
361 mustCreate(t, passwdPath)
363 *a.handler = func(w http.ResponseWriter, r *http.Request) {
364 if r.URL.Path != "/passwd" {
368 fmt.Fprint(w, "root:x:0:0:root:/root:/bin/bash")
369 for i := 0; i < 65536; i++ {
375 err := mainFetch(configPath)
376 mustBeErrorWithSubstring(t, err,
377 "passwd too large to serialize")
379 mustNotExist(t, statePath, plainPath, groupPath)
380 mustBeOld(t, passwdPath)
383 func fetchPasswd(a args) {
385 mustWritePasswdConfig(t, a.url)
386 mustCreate(t, passwdPath)
387 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
389 t.Log("First fetch, write files")
391 *a.handler = func(w http.ResponseWriter, r *http.Request) {
392 if r.URL.Path != "/passwd" {
396 // No "Last-Modified" header
397 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
398 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
401 err := mainFetch(configPath)
406 mustNotExist(t, plainPath, groupPath)
407 mustBeNew(t, passwdPath, statePath)
408 // The actual content of passwdPath is verified by the NSS tests
409 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
411 t.Log("Fetch again, no support for Last-Modified")
413 mustMakeOld(t, passwdPath, statePath)
415 err = mainFetch(configPath)
420 mustNotExist(t, plainPath, groupPath)
421 mustBeNew(t, passwdPath, statePath)
422 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
424 t.Log("Fetch again, support for Last-Modified, but not retrieved yet")
426 mustMakeOld(t, passwdPath, statePath)
428 lastChange := time.Now()
430 *a.handler = func(w http.ResponseWriter, r *http.Request) {
431 if r.URL.Path != "/passwd" {
435 modified := r.Header.Get("If-Modified-Since")
437 x, err := http.ParseTime(modified)
439 t.Fatalf("invalid If-Modified-Since %v",
442 if !x.Before(lastChange.Truncate(time.Second)) {
443 w.WriteHeader(http.StatusNotModified)
448 w.Header().Add("Last-Modified",
449 lastChange.UTC().Format(http.TimeFormat))
450 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
451 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
453 fmt.Fprintln(w, "bin:x:2:2:bin:/bin:/usr/sbin/nologin")
457 err = mainFetch(configPath)
462 mustNotExist(t, plainPath, groupPath)
463 mustBeNew(t, passwdPath, statePath)
464 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
466 t.Log("Fetch again, support for Last-Modified")
468 mustMakeOld(t, passwdPath, statePath)
470 err = mainFetch(configPath)
475 mustNotExist(t, plainPath, groupPath)
476 mustBeOld(t, passwdPath)
477 mustBeNew(t, statePath)
478 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
480 t.Log("Corrupt local passwd cache, fetched again")
482 os.Chmod(passwdPath, 0644) // make writable again
483 mustCreate(t, passwdPath)
484 mustMakeOld(t, passwdPath, statePath)
486 err = mainFetch(configPath)
491 mustNotExist(t, plainPath, groupPath)
492 mustBeNew(t, passwdPath, statePath)
493 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
495 t.Log("Fetch again with newer server response")
498 lastChange = time.Now().Add(time.Second)
500 mustMakeOld(t, passwdPath, statePath)
502 err = mainFetch(configPath)
507 mustNotExist(t, plainPath, groupPath)
508 mustBeNew(t, passwdPath, statePath)
509 mustHaveHash(t, passwdPath, "ca9c7477cb425667fc9ecbd79e8e1c2ad0e84423")
512 func fetchPlainEmpty(a args) {
514 mustWriteConfig(t, fmt.Sprintf(`
522 `, statePath, a.url, plainPath, tlsCAPath))
523 mustCreate(t, plainPath)
525 *a.handler = func(w http.ResponseWriter, r *http.Request) {
529 err := mainFetch(configPath)
530 mustBeErrorWithSubstring(t, err,
531 "refusing to use empty response")
533 mustNotExist(t, statePath, passwdPath, groupPath)
534 mustBeOld(t, plainPath)
537 func fetchPlain(a args) {
539 mustWriteConfig(t, fmt.Sprintf(`
547 `, statePath, a.url, plainPath, tlsCAPath))
548 mustCreate(t, plainPath)
549 mustHaveHash(t, plainPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
551 *a.handler = func(w http.ResponseWriter, r *http.Request) {
552 if r.URL.Path != "/plain" {
556 fmt.Fprintln(w, "some file")
559 err := mainFetch(configPath)
564 mustNotExist(t, passwdPath, groupPath)
565 mustBeNew(t, plainPath, statePath)
566 mustHaveHash(t, plainPath, "0e08b5e8c10abc3e455b75286ba4a1fbd56e18a5")
568 // Remaining functionality already tested in fetchPasswd()
571 func fetchGroupEmpty(a args) {
573 mustWriteGroupConfig(t, a.url)
574 mustCreate(t, groupPath)
576 *a.handler = func(w http.ResponseWriter, r *http.Request) {
580 err := mainFetch(configPath)
581 mustBeErrorWithSubstring(t, err,
582 "refusing to use empty group file")
584 mustNotExist(t, statePath, passwdPath, plainPath)
585 mustBeOld(t, groupPath)
588 func fetchGroupInvalid(a args) {
590 mustWriteGroupConfig(t, a.url)
591 mustCreate(t, groupPath)
593 *a.handler = func(w http.ResponseWriter, r *http.Request) {
594 if r.URL.Path != "/group" {
598 fmt.Fprintln(w, "root:x::")
601 err := mainFetch(configPath)
602 mustBeErrorWithSubstring(t, err,
603 "invalid gid in line")
605 mustNotExist(t, statePath, passwdPath, plainPath)
606 mustBeOld(t, groupPath)
609 func fetchGroupLimits(a args) {
611 mustWriteGroupConfig(t, a.url)
612 mustCreate(t, groupPath)
614 *a.handler = func(w http.ResponseWriter, r *http.Request) {
615 if r.URL.Path != "/group" {
619 fmt.Fprint(w, "root:x:0:")
620 for i := 0; i < 65536; i++ {
626 err := mainFetch(configPath)
627 mustBeErrorWithSubstring(t, err,
628 "group too large to serialize")
630 mustNotExist(t, statePath, passwdPath, plainPath)
631 mustBeOld(t, groupPath)
634 func fetchGroup(a args) {
636 mustWriteGroupConfig(t, a.url)
637 mustCreate(t, groupPath)
638 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
640 *a.handler = func(w http.ResponseWriter, r *http.Request) {
641 if r.URL.Path != "/group" {
645 fmt.Fprintln(w, "root:x:0:")
646 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
649 err := mainFetch(configPath)
654 mustNotExist(t, passwdPath, plainPath)
655 mustBeNew(t, groupPath, statePath)
656 // The actual content of groupPath is verified by the NSS tests
657 mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
659 // Remaining functionality already tested in fetchPasswd()
662 func fetchNoConfig(a args) {
665 err := mainFetch(configPath)
666 mustBeErrorWithSubstring(t, err,
667 configPath+": no such file or directory")
669 mustNotExist(t, configPath, statePath, passwdPath, plainPath, groupPath)
672 func fetchStateCannotRead(a args) {
674 mustWritePasswdConfig(t, a.url)
676 mustCreate(t, statePath)
677 err := os.Chmod(statePath, 0000)
682 err = mainFetch(configPath)
683 mustBeErrorWithSubstring(t, err,
684 statePath+": permission denied")
686 mustNotExist(t, passwdPath, plainPath, groupPath)
689 func fetchStateInvalid(a args) {
691 mustWriteGroupConfig(t, a.url)
692 mustCreate(t, statePath)
694 err := mainFetch(configPath)
695 mustBeErrorWithSubstring(t, err,
696 "unexpected end of JSON input")
698 mustNotExist(t, groupPath, passwdPath, plainPath)
699 mustBeOld(t, statePath)
702 func fetchStateCannotWrite(a args) {
704 mustWriteGroupConfig(t, a.url)
705 mustCreate(t, groupPath)
706 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
708 *a.handler = func(w http.ResponseWriter, r *http.Request) {
709 // To prevent mainFetch() from trying to update groupPath
710 // which will also fail
711 w.WriteHeader(http.StatusNotModified)
714 err := os.Chmod("testdata", 0500)
718 defer os.Chmod("testdata", 0755)
720 err = mainFetch(configPath)
721 mustBeErrorWithSubstring(t, err,
724 mustNotExist(t, statePath, passwdPath, plainPath)
725 mustBeOld(t, groupPath)
728 func fetchCannotDeploy(a args) {
730 mustWriteGroupConfig(t, a.url)
731 mustCreate(t, groupPath)
732 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
734 *a.handler = func(w http.ResponseWriter, r *http.Request) {
735 if r.URL.Path != "/group" {
739 fmt.Fprintln(w, "root:x:0:")
740 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
743 err := os.Chmod("testdata", 0500)
747 defer os.Chmod("testdata", 0755)
749 err = mainFetch(configPath)
750 mustBeErrorWithSubstring(t, err,
753 mustNotExist(t, statePath, passwdPath, plainPath)
754 mustBeOld(t, groupPath)
757 func fetchSecondFetchFails(a args) {
759 mustWriteConfig(t, fmt.Sprintf(`
773 `, statePath, a.url, passwdPath, groupPath, tlsCAPath))
774 mustCreate(t, passwdPath)
775 mustCreate(t, groupPath)
776 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
777 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
779 *a.handler = func(w http.ResponseWriter, r *http.Request) {
780 if r.URL.Path == "/passwd" {
781 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
783 if r.URL.Path == "/group" {
784 w.WriteHeader(http.StatusNotFound)
788 err := mainFetch(configPath)
789 mustBeErrorWithSubstring(t, err,
792 mustNotExist(t, statePath, plainPath)
793 // Even though passwd was successfully fetched, no files were modified
794 // because the second fetch failed
795 mustBeOld(t, passwdPath, groupPath)
798 func fetchInvalidCA(a args) {
803 mustWriteConfig(t, fmt.Sprintf(`
810 `, statePath, a.url, passwdPath))
811 mustCreate(t, passwdPath)
812 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
814 *a.handler = func(w http.ResponseWriter, r *http.Request) {
815 if r.URL.Path == "/passwd" {
816 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
820 err := mainFetch(configPath)
821 mustBeErrorWithSubstring(t, err,
822 "x509: certificate signed by unknown authority")
824 mustNotExist(t, statePath, plainPath, groupPath)
825 mustBeOld(t, passwdPath)
829 mustWriteConfig(t, fmt.Sprintf(`
837 `, statePath, a.url, passwdPath, tlsCA2Path))
838 mustCreate(t, passwdPath)
839 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
841 *a.handler = func(w http.ResponseWriter, r *http.Request) {
842 if r.URL.Path == "/passwd" {
843 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
847 err = mainFetch(configPath)
848 mustBeErrorWithSubstring(t, err,
849 "x509: certificate signed by unknown authority")
851 mustNotExist(t, statePath, plainPath, groupPath)
852 mustBeOld(t, passwdPath)