]> ruderich.org/simon Gitweb - nsscash/nsscash.git/blobdiff - main_test.go
.github: update upstream actions to latest version
[nsscash/nsscash.git] / main_test.go
index 563f683ab5fb6a3e7d443baa8906b66303bec082..b5fc2ea92c57068ba9dc6016d39dfaa8af7b328c 100644 (file)
@@ -1,4 +1,4 @@
-// Copyright (C) 2019  Simon Ruderich
+// Copyright (C) 2019-2021  Simon Ruderich
 //
 // This program is free software: you can redistribute it and/or modify
 // it under the terms of the GNU Affero General Public License as published by
@@ -17,6 +17,7 @@ package main
 
 import (
        "crypto/sha1"
+       "crypto/tls"
        "encoding/hex"
        "fmt"
        "io/ioutil"
@@ -24,6 +25,7 @@ import (
        "net/http"
        "net/http/httptest"
        "os"
+       "path/filepath"
        "reflect"
        "runtime"
        "strings"
@@ -32,11 +34,15 @@ import (
 )
 
 const (
-       configPath = "testdata/config.toml"
-       statePath  = "testdata/state.json"
-       passwdPath = "testdata/passwd.nsscash"
-       plainPath  = "testdata/plain"
-       groupPath  = "testdata/group.nsscash"
+       configPath  = "testdata/config.toml"
+       statePath   = "testdata/var/state.json"
+       passwdPath  = "testdata/passwd.nsscash"
+       plainPath   = "testdata/plain"
+       groupPath   = "testdata/group.nsscash"
+       tlsCAPath   = "testdata/ca.crt"
+       tlsCertPath = "testdata/server.crt"
+       tlsKeyPath  = "testdata/server.key"
+       tlsCA2Path  = "testdata/ca2.crt"
 )
 
 type args struct {
@@ -61,6 +67,12 @@ func mustNotExist(t *testing.T, paths ...string) {
        }
 }
 
+func hashAsHex(x []byte) string {
+       h := sha1.New()
+       h.Write(x)
+       return hex.EncodeToString(h.Sum(nil))
+}
+
 // mustHaveHash checks if the given path content has the given SHA-1 string
 // (in hex).
 func mustHaveHash(t *testing.T, path string, hash string) {
@@ -69,10 +81,7 @@ func mustHaveHash(t *testing.T, path string, hash string) {
                t.Fatal(err)
        }
 
-       h := sha1.New()
-       h.Write(x)
-       y := hex.EncodeToString(h.Sum(nil))
-
+       y := hashAsHex(x)
        if y != hash {
                t.Errorf("%q has unexpected hash %q", path, y)
        }
@@ -104,7 +113,8 @@ statepath = "%[1]s"
 type = "passwd"
 url = "%[2]s/passwd"
 path = "%[3]s"
-`, statePath, url, passwdPath))
+ca = "%[4]s"
+`, statePath, url, passwdPath, tlsCAPath))
 }
 
 func mustWriteGroupConfig(t *testing.T, url string) {
@@ -115,7 +125,8 @@ statepath = "%[1]s"
 type = "group"
 url = "%[2]s/group"
 path = "%[3]s"
-`, statePath, url, groupPath))
+ca = "%[4]s"
+`, statePath, url, groupPath, tlsCAPath))
 }
 
 // mustCreate creates a file, truncating it if it exists. It then changes the
@@ -145,8 +156,8 @@ func mustMakeOld(t *testing.T, paths ...string) {
        }
 }
 
-// mustMakeOld verifies that all paths have a modification time in the past,
-// as set by mustMakeOld().
+// mustBeOld verifies that all paths have a modification time in the past, as
+// set by mustMakeOld.
 func mustBeOld(t *testing.T, paths ...string) {
        for _, p := range paths {
                i, err := os.Stat(p)
@@ -155,8 +166,7 @@ func mustBeOld(t *testing.T, paths ...string) {
                }
 
                mtime := i.ModTime()
-               now := time.Now()
-               if now.Sub(mtime) < time.Hour {
+               if time.Since(mtime) < time.Hour {
                        t.Errorf("%q was recently modified", p)
                }
        }
@@ -171,8 +181,7 @@ func mustBeNew(t *testing.T, paths ...string) {
                }
 
                mtime := i.ModTime()
-               now := time.Now()
-               if now.Sub(mtime) > time.Hour {
+               if time.Since(mtime) > time.Hour {
                        t.Errorf("%q was not recently modified", p)
                }
        }
@@ -187,17 +196,53 @@ func TestMainFetch(t *testing.T) {
                // Perform most tests with passwd for simplicity
                fetchPasswdCacheFileDoesNotExist,
                fetchPasswd404,
+               fetchPasswdUnexpected304,
                fetchPasswdEmpty,
                fetchPasswdInvalid,
                fetchPasswdLimits,
                fetchPasswd,
-               // Tests for plain
+               // Tests for plain and group
                fetchPlainEmpty,
                fetchPlain,
+               fetchGroupEmpty,
+               fetchGroupInvalid,
+               fetchGroupLimits,
+               fetchGroup,
                // Special tests
                fetchNoConfig,
+               fetchStateCannotRead,
+               fetchStateInvalid,
+               fetchStateCannotWrite,
+               fetchCannotDeploy,
+               fetchSecondFetchFails,
+               fetchBasicAuth,
+               // TODO: fetchCannotDeployMultiple,
+       }
+
+       // HTTP tests
+
+       for _, f := range tests {
+               runMainTest(t, f, nil)
        }
 
+       // HTTPS tests
+
+       tests = append(tests, fetchInvalidCA)
+
+       cert, err := tls.LoadX509KeyPair(tlsCertPath, tlsKeyPath)
+       if err != nil {
+               t.Fatal(err)
+       }
+       tls := &tls.Config{
+               Certificates: []tls.Certificate{cert},
+       }
+
+       for _, f := range tests {
+               runMainTest(t, f, tls)
+       }
+}
+
+func runMainTest(t *testing.T, f func(args), tls *tls.Config) {
        cleanup := []string{
                configPath,
                statePath,
@@ -206,39 +251,53 @@ func TestMainFetch(t *testing.T) {
                groupPath,
        }
 
-       for _, f := range tests {
-               // NOTE: This is not guaranteed to work according to reflect's
-               // documentation but seems to work reliable for normal
-               // functions.
-               fn := runtime.FuncForPC(reflect.ValueOf(f).Pointer())
-               name := fn.Name()
-               name = name[strings.LastIndex(name, ".")+1:]
-
-               t.Run(name, func(t *testing.T) {
-                       // Preparation & cleanup
-                       for _, p := range cleanup {
-                               err := os.Remove(p)
-                               if err != nil && !os.IsNotExist(err) {
-                                       t.Fatal(err)
-                               }
-                               // Remove the file at the end of this test
-                               // run, if it was created
-                               defer os.Remove(p)
+       // NOTE: This is not guaranteed to work according to reflect's
+       // documentation but seems to work reliable for normal functions.
+       fn := runtime.FuncForPC(reflect.ValueOf(f).Pointer())
+       name := fn.Name()
+       name = name[strings.LastIndex(name, ".")+1:]
+       if tls != nil {
+               name = "tls" + name
+       }
+
+       t.Run(name, func(t *testing.T) {
+               // Preparation & cleanup
+               for _, p := range cleanup {
+                       err := os.Remove(p)
+                       if err != nil && !os.IsNotExist(err) {
+                               t.Fatal(err)
+                       }
+                       // Remove the file at the end of this test run, if it
+                       // was created
+                       defer os.Remove(p)
+
+                       dir := filepath.Dir(p)
+                       err = os.MkdirAll(dir, 0755)
+                       if err != nil {
+                               t.Fatal(err)
                        }
+                       defer os.Remove(dir) // remove empty directories
+               }
 
-                       var handler func(http.ResponseWriter, *http.Request)
-                       ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+               var handler func(http.ResponseWriter, *http.Request)
+               ts := httptest.NewUnstartedServer(http.HandlerFunc(
+                       func(w http.ResponseWriter, r *http.Request) {
                                handler(w, r)
                        }))
-                       defer ts.Close()
+               if tls == nil {
+                       ts.Start()
+               } else {
+                       ts.TLS = tls
+                       ts.StartTLS()
+               }
+               defer ts.Close()
 
-                       f(args{
-                               t:       t,
-                               url:     ts.URL,
-                               handler: &handler,
-                       })
+               f(args{
+                       t:       t,
+                       url:     ts.URL,
+                       handler: &handler,
                })
-       }
+       })
 }
 
 func fetchPasswdCacheFileDoesNotExist(a args) {
@@ -267,7 +326,25 @@ func fetchPasswd404(a args) {
                "status code 404")
 
        mustNotExist(t, statePath, plainPath, groupPath)
-       mustBeOld(a.t, passwdPath)
+       mustBeOld(t, passwdPath)
+}
+
+func fetchPasswdUnexpected304(a args) {
+       t := a.t
+       mustWritePasswdConfig(t, a.url)
+       mustCreate(t, passwdPath)
+
+       *a.handler = func(w http.ResponseWriter, r *http.Request) {
+               // 304
+               w.WriteHeader(http.StatusNotModified)
+       }
+
+       err := mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               "status code 304 but did not send If-Modified-Since")
+
+       mustNotExist(t, statePath, plainPath, groupPath)
+       mustBeOld(t, passwdPath)
 }
 
 func fetchPasswdEmpty(a args) {
@@ -379,6 +456,7 @@ func fetchPasswd(a args) {
        mustMakeOld(t, passwdPath, statePath)
 
        lastChange := time.Now()
+       change := false
        *a.handler = func(w http.ResponseWriter, r *http.Request) {
                if r.URL.Path != "/passwd" {
                        return
@@ -391,16 +469,19 @@ func fetchPasswd(a args) {
                                t.Fatalf("invalid If-Modified-Since %v",
                                        modified)
                        }
-                       if !x.Before(lastChange) {
+                       if !x.Before(lastChange.Truncate(time.Second)) {
                                w.WriteHeader(http.StatusNotModified)
                                return
                        }
                }
 
                w.Header().Add("Last-Modified",
-                       lastChange.Format(http.TimeFormat))
+                       lastChange.UTC().Format(http.TimeFormat))
                fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
                fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
+               if change {
+                       fmt.Fprintln(w, "bin:x:2:2:bin:/bin:/usr/sbin/nologin")
+               }
        }
 
        err = mainFetch(configPath)
@@ -440,6 +521,22 @@ func fetchPasswd(a args) {
        mustNotExist(t, plainPath, groupPath)
        mustBeNew(t, passwdPath, statePath)
        mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
+
+       t.Log("Fetch again with newer server response")
+
+       change = true
+       lastChange = time.Now().Add(time.Second)
+
+       mustMakeOld(t, passwdPath, statePath)
+
+       err = mainFetch(configPath)
+       if err != nil {
+               t.Error(err)
+       }
+
+       mustNotExist(t, plainPath, groupPath)
+       mustBeNew(t, passwdPath, statePath)
+       mustHaveHash(t, passwdPath, "ca9c7477cb425667fc9ecbd79e8e1c2ad0e84423")
 }
 
 func fetchPlainEmpty(a args) {
@@ -451,7 +548,8 @@ statepath = "%[1]s"
 type = "plain"
 url = "%[2]s/plain"
 path = "%[3]s"
-`, statePath, a.url, plainPath))
+ca = "%[4]s"
+`, statePath, a.url, plainPath, tlsCAPath))
        mustCreate(t, plainPath)
 
        *a.handler = func(w http.ResponseWriter, r *http.Request) {
@@ -475,7 +573,8 @@ statepath = "%[1]s"
 type = "plain"
 url = "%[2]s/plain"
 path = "%[3]s"
-`, statePath, a.url, plainPath))
+ca = "%[4]s"
+`, statePath, a.url, plainPath, tlsCAPath))
        mustCreate(t, plainPath)
        mustHaveHash(t, plainPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
 
@@ -499,6 +598,97 @@ path = "%[3]s"
        // Remaining functionality already tested in fetchPasswd()
 }
 
+func fetchGroupEmpty(a args) {
+       t := a.t
+       mustWriteGroupConfig(t, a.url)
+       mustCreate(t, groupPath)
+
+       *a.handler = func(w http.ResponseWriter, r *http.Request) {
+               // Empty response
+       }
+
+       err := mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               "refusing to use empty group file")
+
+       mustNotExist(t, statePath, passwdPath, plainPath)
+       mustBeOld(t, groupPath)
+}
+
+func fetchGroupInvalid(a args) {
+       t := a.t
+       mustWriteGroupConfig(t, a.url)
+       mustCreate(t, groupPath)
+
+       *a.handler = func(w http.ResponseWriter, r *http.Request) {
+               if r.URL.Path != "/group" {
+                       return
+               }
+
+               fmt.Fprintln(w, "root:x::")
+       }
+
+       err := mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               "invalid gid in line")
+
+       mustNotExist(t, statePath, passwdPath, plainPath)
+       mustBeOld(t, groupPath)
+}
+
+func fetchGroupLimits(a args) {
+       t := a.t
+       mustWriteGroupConfig(t, a.url)
+       mustCreate(t, groupPath)
+
+       *a.handler = func(w http.ResponseWriter, r *http.Request) {
+               if r.URL.Path != "/group" {
+                       return
+               }
+
+               fmt.Fprint(w, "root:x:0:")
+               for i := 0; i < 65536; i++ {
+                       fmt.Fprint(w, "x")
+               }
+               fmt.Fprint(w, "\n")
+       }
+
+       err := mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               "group too large to serialize")
+
+       mustNotExist(t, statePath, passwdPath, plainPath)
+       mustBeOld(t, groupPath)
+}
+
+func fetchGroup(a args) {
+       t := a.t
+       mustWriteGroupConfig(t, a.url)
+       mustCreate(t, groupPath)
+       mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
+
+       *a.handler = func(w http.ResponseWriter, r *http.Request) {
+               if r.URL.Path != "/group" {
+                       return
+               }
+
+               fmt.Fprintln(w, "root:x:0:")
+               fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
+       }
+
+       err := mainFetch(configPath)
+       if err != nil {
+               t.Error(err)
+       }
+
+       mustNotExist(t, passwdPath, plainPath)
+       mustBeNew(t, groupPath, statePath)
+       // The actual content of groupPath is verified by the NSS tests
+       mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
+
+       // Remaining functionality already tested in fetchPasswd()
+}
+
 func fetchNoConfig(a args) {
        t := a.t
 
@@ -508,3 +698,321 @@ func fetchNoConfig(a args) {
 
        mustNotExist(t, configPath, statePath, passwdPath, plainPath, groupPath)
 }
+
+func fetchStateCannotRead(a args) {
+       t := a.t
+       mustWritePasswdConfig(t, a.url)
+
+       mustCreate(t, statePath)
+       err := os.Chmod(statePath, 0000)
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       err = mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               statePath+": permission denied")
+
+       mustNotExist(t, passwdPath, plainPath, groupPath)
+       mustBeOld(t, statePath)
+}
+
+func fetchStateInvalid(a args) {
+       t := a.t
+       mustWriteGroupConfig(t, a.url)
+       mustCreate(t, statePath)
+
+       err := mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               "unexpected end of JSON input")
+
+       mustNotExist(t, groupPath, passwdPath, plainPath)
+       mustBeOld(t, statePath)
+}
+
+func fetchStateCannotWrite(a args) {
+       t := a.t
+       mustWriteGroupConfig(t, a.url)
+       mustCreate(t, groupPath)
+       mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
+
+       *a.handler = func(w http.ResponseWriter, r *http.Request) {
+               if r.URL.Path != "/group" {
+                       return
+               }
+
+               fmt.Fprintln(w, "root:x:0:")
+               fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
+       }
+
+       err := os.Chmod(filepath.Dir(statePath), 0500)
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Chmod(filepath.Dir(statePath), 0755)
+
+       err = mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               "permission denied")
+
+       mustNotExist(t, statePath, passwdPath, plainPath)
+       mustBeNew(t, groupPath)
+       mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
+}
+
+func fetchCannotDeploy(a args) {
+       t := a.t
+       mustWriteGroupConfig(t, a.url)
+       mustCreate(t, groupPath)
+       mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
+
+       *a.handler = func(w http.ResponseWriter, r *http.Request) {
+               if r.URL.Path != "/group" {
+                       return
+               }
+
+               fmt.Fprintln(w, "root:x:0:")
+               fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
+       }
+
+       err := os.Chmod("testdata", 0500)
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Chmod("testdata", 0755)
+
+       err = mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               "permission denied")
+
+       mustNotExist(t, statePath, passwdPath, plainPath)
+       mustBeOld(t, groupPath)
+}
+
+func fetchSecondFetchFails(a args) {
+       t := a.t
+       mustWriteConfig(t, fmt.Sprintf(`
+statepath = "%[1]s"
+
+[[file]]
+type = "passwd"
+url = "%[2]s/passwd"
+path = "%[3]s"
+ca = "%[5]s"
+
+[[file]]
+type = "group"
+url = "%[2]s/group"
+path = "%[4]s"
+ca = "%[5]s"
+`, statePath, a.url, passwdPath, groupPath, tlsCAPath))
+       mustCreate(t, passwdPath)
+       mustCreate(t, groupPath)
+       mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
+       mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
+
+       *a.handler = func(w http.ResponseWriter, r *http.Request) {
+               if r.URL.Path == "/passwd" {
+                       fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
+               }
+               if r.URL.Path == "/group" {
+                       w.WriteHeader(http.StatusNotFound)
+               }
+       }
+
+       err := mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               "status code 404")
+
+       mustNotExist(t, statePath, plainPath)
+       // Even though passwd was successfully fetched, no files were modified
+       // because the second fetch failed
+       mustBeOld(t, passwdPath, groupPath)
+}
+
+func fetchBasicAuth(a args) {
+       t := a.t
+       mustWritePasswdConfig(t, a.url)
+       mustCreate(t, passwdPath)
+       mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
+
+       validUser := "username"
+       validPass := "password"
+
+       *a.handler = func(w http.ResponseWriter, r *http.Request) {
+               if r.URL.Path != "/passwd" {
+                       return
+               }
+
+               user, pass, ok := r.BasicAuth()
+               // NOTE: Do not use this in production because it permits
+               // attackers to determine the length of user/pass. Instead use
+               // hashes and subtle.ConstantTimeCompare().
+               if !ok || user != validUser || pass != validPass {
+                       w.Header().Set("WWW-Authenticate", `Basic realm="Test"`)
+                       w.WriteHeader(http.StatusUnauthorized)
+                       return
+               }
+
+               fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
+               fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
+       }
+
+       t.Log("Missing authentication")
+
+       err := mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               "status code 401")
+
+       mustNotExist(t, statePath, groupPath, plainPath)
+       mustBeOld(t, passwdPath)
+
+       t.Log("Unsafe config permissions")
+
+       mustWriteConfig(t, fmt.Sprintf(`
+statepath = "%[1]s"
+
+[[file]]
+type = "passwd"
+url = "%[2]s/passwd"
+path = "%[3]s"
+ca = "%[4]s"
+username = "%[5]s"
+password = "%[6]s"
+`, statePath, a.url, passwdPath, tlsCAPath, validUser, validPass))
+
+       err = os.Chmod(configPath, 0644)
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       err = mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               "file[0].username/passsword in use and unsafe permissions "+
+                       "-rw-r--r-- on \""+configPath+"\"")
+
+       mustNotExist(t, statePath, groupPath, plainPath)
+       mustBeOld(t, passwdPath)
+
+       t.Log("Working authentication")
+
+       err = os.Chmod(configPath, 0600)
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       err = mainFetch(configPath)
+       if err != nil {
+               t.Error(err)
+       }
+
+       mustNotExist(t, plainPath, groupPath)
+       mustBeNew(t, passwdPath, statePath)
+       mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
+}
+
+func fetchInvalidCA(a args) {
+       t := a.t
+
+       // System CA
+
+       mustWriteConfig(t, fmt.Sprintf(`
+statepath = "%[1]s"
+
+[[file]]
+type = "passwd"
+url = "%[2]s/passwd"
+path = "%[3]s"
+`, statePath, a.url, passwdPath))
+       mustCreate(t, passwdPath)
+       mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
+
+       *a.handler = func(w http.ResponseWriter, r *http.Request) {
+               if r.URL.Path == "/passwd" {
+                       fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
+               }
+       }
+
+       err := mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               "x509: certificate signed by unknown authority")
+
+       mustNotExist(t, statePath, plainPath, groupPath)
+       mustBeOld(t, passwdPath)
+
+       // Invalid CA
+
+       mustWriteConfig(t, fmt.Sprintf(`
+statepath = "%[1]s"
+
+[[file]]
+type = "passwd"
+url = "%[2]s/passwd"
+path = "%[3]s"
+ca = "%[4]s"
+`, statePath, a.url, passwdPath, tlsCA2Path))
+       mustCreate(t, passwdPath)
+       mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
+
+       *a.handler = func(w http.ResponseWriter, r *http.Request) {
+               if r.URL.Path == "/passwd" {
+                       fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
+               }
+       }
+
+       err = mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               "x509: certificate signed by unknown authority")
+
+       mustNotExist(t, statePath, plainPath, groupPath)
+       mustBeOld(t, passwdPath)
+}
+
+/*
+TODO: implement code for this test
+
+func fetchCannotDeployMultiple(a args) {
+       t := a.t
+       newPlainDir := "testdata/x"
+       newPlainPath := newPlainDir + "/plain"
+       mustWriteConfig(t, fmt.Sprintf(`
+statepath = "%[1]s"
+
+[[file]]
+type = "group"
+url = "%[2]s/group"
+path = "%[3]s"
+
+[[file]]
+type = "plain"
+url = "%[2]s/plain"
+path = "%[4]s"
+`, statePath, a.url, groupPath, newPlainPath))
+       os.Mkdir(newPlainDir, 0755)
+       defer os.RemoveAll(newPlainDir)
+       mustCreate(t, groupPath)
+       mustCreate(t, newPlainPath)
+       mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
+
+       *a.handler = func(w http.ResponseWriter, r *http.Request) {
+               if r.URL.Path == "/group" {
+                       fmt.Fprintln(w, "root:x:0:")
+               }
+               if r.URL.Path == "/plain" {
+                       fmt.Fprintln(w, "some file")
+               }
+       }
+
+       err := os.Chmod(newPlainDir, 0500)
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       err = mainFetch(configPath)
+       mustBeErrorWithSubstring(t, err,
+               "permission denied")
+
+       mustNotExist(t, statePath, passwdPath, plainPath)
+       mustBeOld(t, groupPath, newPlainPath)
+}
+*/