diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go index 09e339f..5486a7f 100644 --- a/cmd/boardvoting/handlers.go +++ b/cmd/boardvoting/handlers.go @@ -19,6 +19,7 @@ package main import ( "database/sql" + "encoding/json" "errors" "fmt" "net/http" @@ -399,3 +400,22 @@ func (app *application) deleteUserForm(_ http.ResponseWriter, _ *http.Request) { func (app *application) deleteUserSubmit(_ http.ResponseWriter, _ *http.Request) { panic("not implemented") } + +func (app *application) healthCheck(w http.ResponseWriter, _ *http.Request) { + response := struct { + DB string `json:"database"` + }{DB: "ok"} + + enc := json.NewEncoder(w) + + w.Header().Set("Content-Type", "application/json") + + err := app.motions.DB.Ping() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + + response.DB = "FAILED" + } + + _ = enc.Encode(response) +} diff --git a/cmd/boardvoting/handlers_test.go b/cmd/boardvoting/handlers_test.go new file mode 100644 index 0000000..c772a6d --- /dev/null +++ b/cmd/boardvoting/handlers_test.go @@ -0,0 +1,92 @@ +/* +Copyright 2022 CAcert Inc. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "database/sql" + "net/http" + "net/http/httptest" + "path" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.cacert.org/cacert-boardvoting/internal/models" +) + +func prepareTestDb(t *testing.T) *sqlx.DB { + t.Helper() + + testDir := t.TempDir() + + db, err := sql.Open("sqlite3", path.Join(testDir, "test.sqlite")) + require.NoError(t, err) + + dbx := sqlx.NewDb(db, "sqlite3") + + return dbx +} + +func TestApplication_healthCheck(t *testing.T) { + t.Run("check with valid DB", func(t *testing.T) { + rr := httptest.NewRecorder() + + r, err := http.NewRequest(http.MethodGet, "/health", nil) + require.NoError(t, err) + + testDB := prepareTestDb(t) + + app := &application{ + motions: &models.MotionModel{DB: testDB}, + } + + app.healthCheck(rr, r) + + rs := rr.Result() + + assert.Equal(t, http.StatusOK, rs.StatusCode) + }) + + t.Run("check with broken DB", func(t *testing.T) { + rr := httptest.NewRecorder() + + r, err := http.NewRequest(http.MethodGet, "/health", nil) + require.NoError(t, err) + + testDir := t.TempDir() + + db, err := sql.Open("sqlite3", path.Join(testDir, "test.sqlite")) + require.NoError(t, err) + + testDB := sqlx.NewDb(db, "sqlite3") + + _ = db.Close() + + app := &application{ + motions: &models.MotionModel{DB: testDB}, + } + + app.healthCheck(rr, r) + + rs := rr.Result() + + assert.Equal(t, http.StatusInternalServerError, rs.StatusCode) + }) +} diff --git a/cmd/boardvoting/helpers.go b/cmd/boardvoting/helpers.go index 23d7a24..10c9b41 100644 --- a/cmd/boardvoting/helpers.go +++ b/cmd/boardvoting/helpers.go @@ -134,10 +134,7 @@ func (app *application) newTemplateData( nav topLevelNavItem, subNav subLevelNavItem, ) *templateData { - user, err := app.GetUser(r) - if err != nil { - app.errorLog.Printf("error getting user for template data: %v", err) - } + user, _ := app.GetUser(r) return &templateData{ Request: r, diff --git a/cmd/boardvoting/middleware_test.go b/cmd/boardvoting/middleware_test.go new file mode 100644 index 0000000..70d44ca --- /dev/null +++ b/cmd/boardvoting/middleware_test.go @@ -0,0 +1,169 @@ +/* +Copyright 2022 CAcert Inc. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "log" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.cacert.org/cacert-boardvoting/internal" + "git.cacert.org/cacert-boardvoting/internal/models" +) + +func Test_secureHeaders(t *testing.T) { + rr := httptest.NewRecorder() + + r, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("OK")) + }) + + secureHeaders(next).ServeHTTP(rr, r) + + rs := rr.Result() + + assert.Equal(t, "default-src 'self'; font-src 'self' data:", rs.Header.Get("Content-Security-Policy")) + assert.Equal(t, "origin-when-cross-origin", rs.Header.Get("Referrer-Policy")) + assert.Equal(t, "nosniff", rs.Header.Get("X-Content-Type-Options")) + assert.Equal(t, "deny", rs.Header.Get("X-Frame-Options")) + assert.Equal(t, "0", rs.Header.Get("X-XSS-Protection")) + assert.Equal(t, "max-age=63072000", rs.Header.Get("Strict-Transport-Security")) +} + +func TestApplication_logRequest(t *testing.T) { + rr := httptest.NewRecorder() + + r, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + + r.RemoteAddr = "arg" + + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("OK")) + }) + + buf := new(bytes.Buffer) + app := &application{infoLog: log.New(buf, "", log.LstdFlags)} + + app.logRequest(next).ServeHTTP(rr, r) + + rs := rr.Result() + assert.Equal(t, http.StatusOK, rs.StatusCode) + + assert.Contains(t, buf.String(), fmt.Sprintf( + "%s - %s %s %s", + r.RemoteAddr, + r.Proto, + r.Method, + r.URL.RequestURI(), + )) +} + +func TestApplication_tryAuthenticate(t *testing.T) { + db := prepareTestDb(t) + + err := internal.InitializeDb(db.DB, log.New(os.Stdout, "", log.LstdFlags)) + + require.NoError(t, err) + + users := &models.UserModel{DB: db} + + _, err = users.CreateUser( + context.Background(), + "Test User", + "test@example.org", + []string{"test@example.org"}, + ) + + var nextCtx context.Context + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("OK")) + + nextCtx = r.Context() + }) + + require.NoError(t, err) + + app := application{ + users: &models.UserModel{DB: db}, + } + + t.Run("without TLS", func(t *testing.T) { + rr := httptest.NewRecorder() + + r, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + + app.tryAuthenticate(next).ServeHTTP(rr, r) + + rs := rr.Result() + assert.Equal(t, http.StatusOK, rs.StatusCode) + assert.Nil(t, nextCtx.Value(ctxUser)) + }) + + t.Run("with TLS no certificate", func(t *testing.T) { + rr := httptest.NewRecorder() + + r, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + + r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{}} + + app.tryAuthenticate(next).ServeHTTP(rr, r) + + rs := rr.Result() + assert.Equal(t, http.StatusOK, rs.StatusCode) + assert.Nil(t, nextCtx.Value(ctxUser)) + }) + + t.Run("with TLS matching user", func(t *testing.T) { + rr := httptest.NewRecorder() + + r, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + + r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{EmailAddresses: []string{"test@example.org"}}}} + + app.tryAuthenticate(next).ServeHTTP(rr, r) + + rs := rr.Result() + assert.Equal(t, http.StatusOK, rs.StatusCode) + user := nextCtx.Value(ctxUser) + + assert.NotNil(t, user) + + userInstance, ok := user.(*models.User) + assert.True(t, ok) + + assert.Equal(t, userInstance.Name, "Test User") + }) +} diff --git a/cmd/boardvoting/routes.go b/cmd/boardvoting/routes.go index 42bc2d7..a912e6c 100644 --- a/cmd/boardvoting/routes.go +++ b/cmd/boardvoting/routes.go @@ -91,6 +91,8 @@ func (app *application) routes() http.Handler { router.Handler(http.MethodGet, "/users/:id/delete", canManageUsers.ThenFunc(app.deleteUserForm)) router.Handler(http.MethodPost, "/users/:id/delete", canManageUsers.ThenFunc(app.deleteUserSubmit)) + router.HandlerFunc(http.MethodGet, "/health", app.healthCheck) + standard := alice.New(app.logRequest, secureHeaders) return standard.Then(router) diff --git a/internal/migrations/2022052601_drop_unused_decisions_colums.down.sql b/internal/migrations/2022052601_drop_unused_decisions_colums.down.sql deleted file mode 100644 index 22c626a..0000000 --- a/internal/migrations/2022052601_drop_unused_decisions_colums.down.sql +++ /dev/null @@ -1,3 +0,0 @@ --- drop unused columns majority and quorum from decisions table -ALTER TABLE decisions ADD COLUMN quorum INTEGER; -ALTER TABLE decisions ADD COLUMN majority INTEGER; diff --git a/internal/migrations/2022052601_drop_unused_decisions_colums.up.sql b/internal/migrations/2022052601_drop_unused_decisions_colums.up.sql deleted file mode 100644 index 51ca627..0000000 --- a/internal/migrations/2022052601_drop_unused_decisions_colums.up.sql +++ /dev/null @@ -1,3 +0,0 @@ --- drop unused columns majority and quorum from decisions table -ALTER TABLE decisions DROP COLUMN quorum; -ALTER TABLE decisions DROP COLUMN majority; diff --git a/internal/models/voters.go b/internal/models/voters.go index 14bc82a..01a05d6 100644 --- a/internal/models/voters.go +++ b/internal/models/voters.go @@ -159,3 +159,37 @@ func (m *UserModel) GetRoles(ctx context.Context, user *User) ([]*Role, error) { return result, nil } + +func (m *UserModel) CreateUser(ctx context.Context, name string, reminder string, emails []string) (int64, error) { + tx, err := m.DB.BeginTxx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("could not start transaction: %w", err) + } + + defer func(tx *sqlx.Tx) { + _ = tx.Rollback() + }(tx) + + res, err := tx.Exec(`INSERT INTO voters (name, reminder, enabled) VALUES (?, ?, 0)`, name, reminder) + if err != nil { + return 0, fmt.Errorf("could not insert user: %w", err) + } + + userID, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("could not get user id: %w", err) + } + + for i := range emails { + _, err := tx.Exec(`INSERT INTO emails (voter, address) VALUES (?, ?)`, userID, emails[i]) + if err != nil { + return 0, fmt.Errorf("could not insert email %s for voter %s: %w", emails[i], name, err) + } + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("could not commit user transaction: %w", err) + } + + return userID, nil +}