feat(auth): invite links for several people, invite by email, registration with approval or email domains
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m20s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m20s
- Invites: link /?invite=CODE, copy link, send by email, max uses with counter - Registration modes closed | approval | domain | domain+approval | open, waiting list with approve/reject - REGISTRATION_MODE / REGISTRATION_DOMAINS lock the admin setting; ALLOW_REGISTRATION=true is honoured Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
db5c5cde9b
commit
af77b9e809
24 files changed
+1077
-142
No files matched your search
@@ -0,0 +1,157 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
func setRegistration(t *testing.T, store *storage.Store, mode, domains string) {
|
||||
t.Helper()
|
||||
sys, _ := store.GetSystemSettings()
|
||||
sys.RegistrationMode, sys.RegistrationDomains = mode, domains
|
||||
if err := store.UpdateSystemSettings(sys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func register(srv *Server, email, invite string) (int, map[string]interface{}) {
|
||||
body := `{"email":"` + email + `","password":"password123","displayName":"` + strings.Split(email, "@")[0] + `","inviteCode":"` + invite + `"}`
|
||||
rec := sessionCall(srv.Router(), "POST", "/api/v1/auth/register", "", body, "")
|
||||
var out map[string]interface{}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec.Code, out
|
||||
}
|
||||
|
||||
func TestRegistrationModes(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
|
||||
setRegistration(t, store, storage.RegistrationClosed, "")
|
||||
if code, _ := register(srv, "a@example.com", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("closed: want 403, got %d", code)
|
||||
}
|
||||
|
||||
setRegistration(t, store, storage.RegistrationOpen, "")
|
||||
if code, out := register(srv, "b@example.com", ""); code != http.StatusCreated || out["token"] == nil {
|
||||
t.Fatalf("open: want 201 with token, got %d %v", code, out)
|
||||
}
|
||||
|
||||
setRegistration(t, store, storage.RegistrationDomain, "firma.de, @familie.de")
|
||||
if code, _ := register(srv, "c@fremd.de", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("domain: foreign address must be refused, got %d", code)
|
||||
}
|
||||
if code, _ := register(srv, "c@Familie.DE", ""); code != http.StatusCreated {
|
||||
t.Fatalf("domain: listed domain (any case) must be active at once, got %d", code)
|
||||
}
|
||||
|
||||
setRegistration(t, store, storage.RegistrationDomainApproval, "firma.de")
|
||||
if code, _ := register(srv, "d@firma.de", ""); code != http.StatusCreated {
|
||||
t.Fatalf("domain+approval: listed domain active at once, got %d", code)
|
||||
}
|
||||
if code, out := register(srv, "e@fremd.de", ""); code != http.StatusAccepted || out["pending"] != true || out["token"] != nil {
|
||||
t.Fatalf("domain+approval: others wait without session, got %d %v", code, out)
|
||||
}
|
||||
|
||||
// An invite skips mode and domain check
|
||||
setRegistration(t, store, storage.RegistrationClosed, "")
|
||||
inv, _ := store.CreateUserInvite("", "", "member", "", 7, 1)
|
||||
if code, _ := register(srv, "f@fremd.de", inv.Code); code != http.StatusCreated {
|
||||
t.Fatalf("invite must work in closed mode, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalFlow(t *testing.T) {
|
||||
srv, store, adminToken := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
setRegistration(t, store, storage.RegistrationApproval, "")
|
||||
|
||||
if code, _ := register(srv, "neu@example.com", ""); code != http.StatusAccepted {
|
||||
t.Fatalf("approval: want 202, got %d", code)
|
||||
}
|
||||
login := func() *http.Response {
|
||||
rec := sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"neu@example.com","password":"password123"}`, "")
|
||||
return rec.Result()
|
||||
}
|
||||
res := login()
|
||||
var errBody map[string]string
|
||||
_ = json.NewDecoder(res.Body).Decode(&errBody)
|
||||
if res.StatusCode != http.StatusForbidden || !strings.Contains(errBody["error"], "Freigabe") {
|
||||
t.Fatalf("login before approval: want 403 mentioning Freigabe, got %d %v", res.StatusCode, errBody)
|
||||
}
|
||||
|
||||
// The admin sees the account as waiting and approves it
|
||||
var users []domain.User
|
||||
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/admin/users", adminToken, "", "").Body.Bytes(), &users)
|
||||
var waiting *domain.User
|
||||
for i := range users {
|
||||
if users[i].Email == "neu@example.com" {
|
||||
waiting = &users[i]
|
||||
}
|
||||
}
|
||||
if waiting == nil || !waiting.PendingApproval || waiting.IsActive {
|
||||
t.Fatalf("admin list must show the waiting account, got %+v", waiting)
|
||||
}
|
||||
_, memberToken := newTestUser(t, store, "member@example.com")
|
||||
if rec := sessionCall(router, "POST", "/api/v1/admin/users/"+waiting.ID+"/approve", memberToken, "", ""); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("members must not approve, got %d", rec.Code)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/admin/users/"+waiting.ID+"/approve", adminToken, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("approve: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if res := login(); res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login after approval: want 200, got %d", res.StatusCode)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/admin/users/"+waiting.ID+"/approve", adminToken, "", ""); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("approving twice: want 409, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteLinkForSeveralPeople(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
if _, err := store.CreateUserInvite("", "x@example.com", "member", "", 7, 3); err == nil {
|
||||
t.Fatal("a link for several people must not be bound to one address")
|
||||
}
|
||||
inv, err := store.CreateUserInvite("", "", "member", "", 7, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, email := range []string{"eins@example.com", "zwei@example.com"} {
|
||||
if code, out := register(srv, email, inv.Code); code != http.StatusCreated {
|
||||
t.Fatalf("%s: want 201, got %d %v", email, code, out)
|
||||
}
|
||||
}
|
||||
if code, out := register(srv, "drei@example.com", inv.Code); code != http.StatusForbidden || !strings.Contains(out["error"].(string), "ausgeschöpft") {
|
||||
t.Fatalf("third use: want 403 'ausgeschöpft', got %d %v", code, out)
|
||||
}
|
||||
invites, _ := store.GetUserInvites()
|
||||
if len(invites) != 1 || invites[0].UseCount != 2 || invites[0].MaxUses != 2 {
|
||||
t.Fatalf("use count wrong: %+v", invites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationFixedByEnvironment(t *testing.T) {
|
||||
srv, store, adminToken := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
srv.cfg.RegistrationMode = storage.RegistrationClosed
|
||||
|
||||
// The admin form shows the fixed value as locked and cannot change it
|
||||
rec := sessionCall(router, "PUT", "/api/v1/admin/settings", adminToken, `{"registrationMode":"open","allowRegistration":true,"instanceName":"MindSift"}`, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save settings: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var sys domain.SystemSettings
|
||||
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/admin/settings", adminToken, "", "").Body.Bytes(), &sys)
|
||||
if sys.RegistrationMode != storage.RegistrationClosed || !sys.RegistrationConfiguredViaEnv {
|
||||
t.Fatalf("environment must win and be marked, got %+v", sys)
|
||||
}
|
||||
if code, _ := register(srv, "x@example.com", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("registration must stay closed, got %d", code)
|
||||
}
|
||||
if stored, _ := store.GetSystemSettings(); stored.RegistrationMode == storage.RegistrationOpen {
|
||||
t.Fatalf("the locked value must not be overwritten in the database")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user