package main import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) func TestTokenStore(t *testing.T) { path := filepath.Join(t.TempDir(), "api_tokens.json") s, err := LoadTokenStore(path) if err != nil { t.Fatal(err) } if _, _, err := s.Create(" "); err == nil { t.Fatal("a token without a name should be refused") } info, secret, err := s.Create("n8n") if err != nil { t.Fatal(err) } if !strings.HasPrefix(secret, tokenPrefix) || len(secret) < 40 || !strings.HasPrefix(secret, info.Hint) { t.Fatalf("token %q, hint %q", secret, info.Hint) } b, _ := os.ReadFile(path) if strings.Contains(string(b), secret) || !strings.Contains(string(b), hashToken(secret)) { t.Fatal("the file must hold the hash of the token, not the token") } if st, _ := os.Stat(path); st.Mode().Perm() != 0o600 { t.Fatalf("file mode %v, want 0600", st.Mode().Perm()) } if _, ok := s.Check(secret+"x", "10.0.0.1"); ok { t.Fatal("a wrong token was accepted") } if name, ok := s.Check(secret, "10.0.0.1"); !ok || name != "n8n" { t.Fatal("the token was refused") } // Reloaded from disk (restart): still valid, last use kept. s2, err := LoadTokenStore(path) if err != nil { t.Fatal(err) } if l := s2.List(); len(l) != 1 || l[0].LastIP != "10.0.0.1" || l[0].LastUsed.IsZero() { t.Fatalf("after reload: %+v", l) } if _, ok := s2.Check(secret, "10.0.0.2"); !ok { t.Fatal("token refused after reload") } if err := s2.Delete(info.ID); err != nil { t.Fatal(err) } if _, ok := s2.Check(secret, "10.0.0.2"); ok { t.Fatal("a revoked token was accepted") } if err := s2.Delete(info.ID); err != errTokenNotFound { t.Fatalf("second delete: %v", err) } } func TestTokenAuth(t *testing.T) { loginFailDelay = 0 tokens, _ := LoadTokenStore(filepath.Join(t.TempDir(), "api_tokens.json")) _, secret, _ := tokens.Create("n8n") var reached string mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { reached = r.URL.Path if isViewer(r) { reached += " viewer" } }) app := readOnly(mux) login := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeAuthRequired(w) }) h := tokenAuth(tokens, login, app) call := func(method, path, auth string) int { reached = "" req := httptest.NewRequest(method, path, nil) if auth != "" { req.Header.Set("Authorization", auth) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec.Code } if code := call("GET", "/api/v1/logs", "Bearer "+secret); code != http.StatusOK || reached != "/api/v1/logs viewer" { t.Fatalf("valid token: %d, reached %q", code, reached) } if code := call("GET", "/api/v1/logs", "bearer "+secret); code != http.StatusOK { t.Fatalf("lower-case scheme: %d", code) } for _, tc := range []struct { method, path, auth string want int }{ {"GET", "/api/v1/logs", "Bearer lst_wrong", http.StatusUnauthorized}, {"GET", "/api/v1/logs", "Bearer ", http.StatusUnauthorized}, {"GET", "/api/tags", "Bearer " + secret, http.StatusForbidden}, {"GET", "/api/tokens", "Bearer " + secret, http.StatusForbidden}, {"POST", "/api/v1/logs", "Bearer " + secret, http.StatusForbidden}, {"GET", "/api/v1/logs", "", http.StatusUnauthorized}, // no token: the login decides {"GET", "/api/v1/logs", "Basic dTpw", http.StatusUnauthorized}, // Basic goes to the login } { if code := call(tc.method, tc.path, tc.auth); code != tc.want || reached != "" { t.Errorf("%s %s %q: %d (reached %q), want %d", tc.method, tc.path, tc.auth, code, reached, tc.want) } } } // With local authentication, a token opens /api/v1/ without a session. func TestTokenWithLocalAuth(t *testing.T) { loginFailDelay = 0 dir := t.TempDir() tokens, _ := LoadTokenStore(filepath.Join(dir, "api_tokens.json")) _, secret, _ := tokens.Create("script") app := readOnly(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) })) auth, err := newAuth(authConfig{mode: "local", user: "admin", pass: "pw", dataDir: dir, sessionTTL: time.Hour}, app) if err != nil { t.Fatal(err) } h := tokenAuth(tokens, auth, app) req := httptest.NewRequest("GET", "/api/v1/logs", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("without token: %d", rec.Code) } req.Header.Set("Authorization", "Bearer "+secret) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK || rec.Body.String() != "ok" { t.Fatalf("with token: %d %s", rec.Code, rec.Body) } } func TestTokenHandlers(t *testing.T) { tokens, _ := LoadTokenStore(filepath.Join(t.TempDir(), "api_tokens.json")) a := &API{tokens: tokens} mux := http.NewServeMux() a.Routes(mux) do := func(r *http.Request) *httptest.ResponseRecorder { rec := httptest.NewRecorder() readOnly(mux).ServeHTTP(rec, r) return rec } rec := do(httptest.NewRequest("POST", "/api/tokens", strings.NewReader(`{"name":"n8n"}`))) var created struct { ID, Token, Hint string } if err := json.Unmarshal(rec.Body.Bytes(), &created); rec.Code != http.StatusCreated || err != nil || created.Token == "" { t.Fatalf("create: %d %s", rec.Code, rec.Body) } rec = do(httptest.NewRequest("GET", "/api/tokens", nil)) if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), created.Token) || strings.Contains(rec.Body.String(), "hash") { t.Fatalf("list must not show the token or its hash: %s", rec.Body) } if rec := do(asViewer(httptest.NewRequest("GET", "/api/tokens", nil))); rec.Code != http.StatusForbidden { t.Fatalf("viewer list: %d", rec.Code) } if rec := do(asViewer(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil))); rec.Code != http.StatusForbidden { t.Fatalf("viewer delete: %d", rec.Code) } if rec := do(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil)); rec.Code != http.StatusNoContent { t.Fatalf("delete: %d", rec.Code) } if rec := do(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil)); rec.Code != http.StatusNotFound { t.Fatalf("delete again: %d", rec.Code) } }