167 lines
6.1 KiB
Go
167 lines
6.1 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gardomatic.kleiax.de/internal/auth"
|
|
"gardomatic.kleiax.de/internal/mailer"
|
|
"gardomatic.kleiax.de/internal/storage"
|
|
"github.com/julienschmidt/httprouter"
|
|
)
|
|
|
|
type adminInviteUserModel struct {
|
|
sessionTestUserModel
|
|
stored storage.User
|
|
deletedID int
|
|
deleteErr error
|
|
}
|
|
|
|
func (m *adminInviteUserModel) Insert(user storage.User) (storage.User, error) {
|
|
user.ID = 42
|
|
m.stored = user
|
|
return user, nil
|
|
}
|
|
|
|
func (m *adminInviteUserModel) GetByEmail(email string) (storage.User, error) {
|
|
if m.stored.ID == 0 || m.stored.Email != email {
|
|
return storage.User{}, storage.ErrRecordNotFound
|
|
}
|
|
return m.stored, nil
|
|
}
|
|
|
|
func (m *adminInviteUserModel) GetForToken(scope, token string) (storage.User, error) {
|
|
if scope != auth.ScopeActivation || token == "" || m.stored.ID == 0 {
|
|
return storage.User{}, storage.ErrRecordNotFound
|
|
}
|
|
return m.stored, nil
|
|
}
|
|
|
|
func (m *adminInviteUserModel) Update(user storage.User) (storage.User, error) {
|
|
m.stored = user
|
|
return user, nil
|
|
}
|
|
|
|
func (m *adminInviteUserModel) Delete(userID int) error {
|
|
m.deletedID = userID
|
|
return m.deleteErr
|
|
}
|
|
|
|
type adminInviteTokenModel struct {
|
|
token auth.Token
|
|
deleted bool
|
|
}
|
|
|
|
func (m *adminInviteTokenModel) New(userID int, ttl time.Duration, scope string) (auth.Token, error) {
|
|
m.token = auth.NewToken(userID, ttl, scope)
|
|
return m.token, nil
|
|
}
|
|
|
|
func (m *adminInviteTokenModel) DeleteAllForUser(scope string, userID int) error {
|
|
m.deleted = scope == auth.ScopeActivation && userID == 42
|
|
return nil
|
|
}
|
|
|
|
func TestAdminInvitationCreatesAccountAndAllowsInitialPassword(t *testing.T) {
|
|
users := new(adminInviteUserModel)
|
|
tokens := new(adminInviteTokenModel)
|
|
mailPath := filepath.Join(t.TempDir(), "mail.log")
|
|
configuredMailer, err := mailer.New(mailer.Config{Mode: mailer.ModeFile, Sender: "gardomatic@example.com", FilePath: mailPath})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app, _, _ := newGardenTestApplication()
|
|
app.config.WebBaseURL = "https://garden.example.com"
|
|
app.models.Users = users
|
|
app.models.Tokens = tokens
|
|
app.mailer = configuredMailer
|
|
|
|
inviteResponse := httptest.NewRecorder()
|
|
inviteRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":" Ada ","email":" ADA@EXAMPLE.COM "}`))
|
|
app.inviteAdminUserHandler(inviteResponse, inviteRequest)
|
|
if inviteResponse.Code != http.StatusAccepted {
|
|
t.Fatalf("invite status: got %d, want %d; body: %s", inviteResponse.Code, http.StatusAccepted, inviteResponse.Body.String())
|
|
}
|
|
if users.stored.Name != "Ada" || users.stored.Email != "ada@example.com" || users.stored.Activated {
|
|
t.Fatalf("unexpected invited user: %+v", users.stored)
|
|
}
|
|
if !tokens.deleted || tokens.token.UserID != users.stored.ID || tokens.token.Scope != auth.ScopeActivation {
|
|
t.Fatalf("unexpected invitation token: %+v", tokens.token)
|
|
}
|
|
firstToken := tokens.token.Plaintext
|
|
resendResponse := httptest.NewRecorder()
|
|
resendRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":"Ada","email":"ada@example.com"}`))
|
|
app.inviteAdminUserHandler(resendResponse, resendRequest)
|
|
if resendResponse.Code != http.StatusAccepted {
|
|
t.Fatalf("resend status: got %d, want %d; body: %s", resendResponse.Code, http.StatusAccepted, resendResponse.Body.String())
|
|
}
|
|
if tokens.token.Plaintext == firstToken {
|
|
t.Fatal("resending did not replace the activation token")
|
|
}
|
|
mailContent, err := os.ReadFile(mailPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"ada@example.com", "Einladung zu Gardomatic", "set-password=3D1", tokens.token.Plaintext} {
|
|
if !strings.Contains(string(mailContent), want) {
|
|
t.Errorf("invitation is missing %q: %s", want, mailContent)
|
|
}
|
|
}
|
|
|
|
activateResponse := httptest.NewRecorder()
|
|
activateBody := `{"token":"` + tokens.token.Plaintext + `","password":"correct horse battery staple"}`
|
|
activateRequest := httptest.NewRequest(http.MethodPut, "/v1/users/activated", strings.NewReader(activateBody))
|
|
app.activateUserHandler(activateResponse, activateRequest)
|
|
if activateResponse.Code != http.StatusOK {
|
|
t.Fatalf("activation status: got %d, want %d; body: %s", activateResponse.Code, http.StatusOK, activateResponse.Body.String())
|
|
}
|
|
if !users.stored.Activated {
|
|
t.Fatal("invited user was not activated")
|
|
}
|
|
matches, err := users.stored.Password.Matches("correct horse battery staple")
|
|
if err != nil || !matches {
|
|
t.Fatalf("initial password was not stored: matches=%t err=%v", matches, err)
|
|
}
|
|
|
|
duplicateResponse := httptest.NewRecorder()
|
|
duplicateRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":"Ada","email":"ada@example.com"}`))
|
|
app.inviteAdminUserHandler(duplicateResponse, duplicateRequest)
|
|
if duplicateResponse.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("active duplicate status: got %d, want %d; body: %s", duplicateResponse.Code, http.StatusUnprocessableEntity, duplicateResponse.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDeleteAdminUserProtectsSelfAndReportsOwnerConflict(t *testing.T) {
|
|
users := new(adminInviteUserModel)
|
|
app, _, _ := newGardenTestApplication()
|
|
app.models.Users = users
|
|
router := httprouter.New()
|
|
router.HandlerFunc(http.MethodDelete, "/v1/admin/users/:id", app.deleteAdminUserHandler)
|
|
|
|
serve := func(actorID, targetID int) *httptest.ResponseRecorder {
|
|
request := httptest.NewRequest(http.MethodDelete, "/v1/admin/users/"+strconv.Itoa(targetID), nil)
|
|
request = app.contextSetAuthenticatedUser(request, storage.User{ID: actorID})
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
return response
|
|
}
|
|
|
|
if response := serve(7, 7); response.Code != http.StatusForbidden || users.deletedID != 0 {
|
|
t.Fatalf("self deletion: status=%d deleted=%d", response.Code, users.deletedID)
|
|
}
|
|
users.deleteErr = storage.ErrConflict
|
|
if response := serve(7, 8); response.Code != http.StatusConflict || users.deletedID != 8 {
|
|
t.Fatalf("owner deletion: status=%d deleted=%d", response.Code, users.deletedID)
|
|
}
|
|
users.deleteErr = nil
|
|
if response := serve(7, 9); response.Code != http.StatusNoContent || users.deletedID != 9 {
|
|
t.Fatalf("deletion: status=%d deleted=%d", response.Code, users.deletedID)
|
|
}
|
|
}
|