package cabana_test import ( "fmt" "net/http" "strings" "testing" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" ) func TestBackendScreensPostgresSmoke(t *testing.T) { env := newScreensEnv(t) root, rootToken := env.admin(t, "screens-root", true, "") dev, devToken := env.admin(t, "screens-dev", false, "") list := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, rootToken, nil) ids := listIDs(t, list) if !ids[root.ID] || !ids[dev.ID] { t.Fatalf("superuser list ids = %v, want %d and %d", ids, root.ID, dev.ID) } show := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, dev.ID), rootToken, nil) if names := recordActionNames(show); len(names) == 0 || names[len(names)-1] != "acme_ping" { t.Fatalf("meta.actions = %v, want acme_ping last", names) } result := env.expect(t, http.StatusOK, http.MethodPost, fmt.Sprintf("%s/%d/actions/acme_ping", usersPath, dev.ID), rootToken, map[string]any{}) if data, _ := result["data"].(map[string]any); data["message"] != "Pinged" { t.Fatalf("action result = %v", result) } runs, withTx := env.spy.runs() if len(runs) != 1 || runs[0] != uint64(dev.ID) || !withTx[0] { t.Fatalf("acme_ping runs = %v tx = %v", runs, withTx) } settings := env.expect(t, http.StatusOK, http.MethodGet, "/settings", rootToken, nil) found := false entries, _ := settings["data"].([]any) for _, entry := range entries { item, _ := entry.(map[string]any) if item["code"] == "administrators" { found = item["controller"] == cabana.AdministratorsControllerID && item["category"] == "System" && item["label"] == "Administrators" } } if !found { t.Fatalf("superuser settings = %v", entries) } if rec := env.call(t, http.MethodGet, usersPath, devToken, nil); rec.Code != http.StatusForbidden { t.Fatalf("developer list status=%d body=%s", rec.Code, rec.Body.String()) } settings = env.expect(t, http.StatusOK, http.MethodGet, "/settings", devToken, nil) if strings.Contains(fmt.Sprint(settings["data"]), "administrators") { t.Fatalf("developer settings list administrators: %v", settings["data"]) } } func TestAdministratorsFormSmoke(t *testing.T) { env := newScreensEnv(t) root, rootToken := env.admin(t, "form-root", true, "") // Create with password and confirmation (D-11, D-13). login := "form-new-" + env.stamp created := env.expect(t, http.StatusCreated, http.MethodPost, usersPath, rootToken, map[string]any{ "login": login, "email": login + "@example.test", "first_name": "New", "last_name": "Admin", "password": "pass-1234", "password_confirmation": "pass-1234", }) data, _ := created["data"].(map[string]any) newID := uint(data["id"].(float64)) stored := env.storedUser(t, newID) if !stored.IsActivated || stored.ActivatedAt == nil || stored.IsSuperuser || !bouncer.CheckPassword(stored.Password, "pass-1234") { t.Fatalf("created admin = %+v", stored) } // A taken login answers 422 on the field. details := env.expectRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, usersPath, rootToken, map[string]any{ "login": strings.ToUpper(login), "email": "other-" + login + "@example.test", "password": "pass-1234", "password_confirmation": "pass-1234", }, "") if !strings.Contains(fmt.Sprint(details["login"]), "already used") { t.Fatalf("taken login details = %v", details) } // Pitfall 1: a superuser reads the stored flag, and an untouched save keeps it. own := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken, nil) if data, _ := own["data"].(map[string]any); data["is_superuser"] != true { t.Fatalf("own record data = %v", own["data"]) } // D-08: the superuser cannot switch off their own flag. env.expectRefusal(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken, map[string]any{"login": root.Login, "email": root.Email, "is_superuser": false}, "your own role") if !env.storedUser(t, root.ID).IsSuperuser { t.Fatal("the superuser lost the flag") } // D-20: an own password is changed in My account, not here. env.expectRefusal(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken, map[string]any{"login": root.Login, "email": root.Email, "password": "pass-5678", "password_confirmation": "pass-5678"}, "My account") // D-09 (sequential path): with a second superuser, demoting it succeeds. second, _ := env.admin(t, "form-second", true, "") env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, second.ID), rootToken, map[string]any{"login": second.Login, "email": second.Email, "is_superuser": false}) if env.storedUser(t, second.ID).IsSuperuser { t.Fatal("the second superuser was not demoted") } shown := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, second.ID), rootToken, nil) if data, _ := shown["data"].(map[string]any); data["is_superuser"] != false { t.Fatalf("demoted record data = %v", shown["data"]) } // A limited administrator holding backend.manage_users (D-03, D-05). limitedUser, limitedToken := env.admin(t, "form-limited", false, `{"backend.manage_users":1}`) peer, _ := env.admin(t, "form-peer", false, `{"backend.manage_users":1}`) env.expectRefusal(t, http.StatusNotFound, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, root.ID), limitedToken, nil, "") schema := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", limitedToken, nil) // The permissions field's trigger still names is_superuser (D-21: a // controlling field missing from the schema counts as off); the switch // itself must be gone. schemaData, _ := schema["data"].(map[string]any) schemaFields, _ := schemaData["fields"].([]any) if len(schemaFields) == 0 { t.Fatalf("form schema = %v", schema["data"]) } for _, item := range schemaFields { if field, _ := item.(map[string]any); field["name"] == "is_superuser" { t.Fatal("the form schema shows is_superuser to a non-superuser") } } env.expectRefusal(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, peer.ID), limitedToken, map[string]any{"login": peer.Login, "email": peer.Email, "is_superuser": false}, "Only a superuser") // The same editor may rename the peer: the hidden field stays out of the body. env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, peer.ID), limitedToken, map[string]any{"login": peer.Login, "email": peer.Email, "first_name": "Renamed"}) if got := env.storedUser(t, peer.ID); got.FirstName != "Renamed" || got.IsSuperuser { t.Fatalf("renamed peer = %+v", got) } // D-08: a change of the editor's own role is refused after assignment // and rolled back. limited := env.storedUser(t, limitedUser.ID) if limited.RoleID == nil || peer.RoleID == nil { t.Fatalf("fixture roles: %+v %+v", limited, peer) } env.expectRefusal(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, limited.ID), limitedToken, map[string]any{"login": limited.Login, "email": limited.Email, "role": *peer.RoleID}, "your own role") if got := env.storedUser(t, limited.ID); got.RoleID == nil || *got.RoleID != *limited.RoleID { t.Fatalf("own role changed to %v", got.RoleID) } } // rowStates returns a list response's meta.row_states for id. func rowStates(body map[string]any, id uint) []string { meta, _ := body["meta"].(map[string]any) states, _ := meta["row_states"].(map[string]any) raw, _ := states[fmt.Sprint(id)].([]any) out := []string{} for _, state := range raw { if text, ok := state.(string); ok { out = append(out, text) } } return out } func TestAdministratorsLifecycleSmoke(t *testing.T) { env := newScreensEnv(t) root, rootToken := env.admin(t, "life-root", true, "") // Deactivate by record action ends the target's sessions at once (D-12). target, targetToken := env.admin(t, "life-target", false, "") if rec := env.call(t, http.MethodGet, "/auth/me", targetToken, nil); rec.Code != http.StatusOK { t.Fatalf("target /auth/me before status=%d", rec.Code) } show := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, target.ID), rootToken, nil) if names := recordActionNames(show); len(names) == 0 || names[0] != "deactivate" { t.Fatalf("offered actions = %v, want deactivate first", names) } env.expect(t, http.StatusOK, http.MethodPost, fmt.Sprintf("%s/%d/actions/deactivate", usersPath, target.ID), rootToken, map[string]any{}) if got := env.storedUser(t, target.ID); got.IsActivated || got.TokensValidAfter == nil { t.Fatalf("deactivated target = %+v", got) } if rec := env.call(t, http.MethodGet, "/auth/me", targetToken, nil); rec.Code != http.StatusUnauthorized { t.Fatalf("target /auth/me after deactivate status=%d body=%s", rec.Code, rec.Body.String()) } // The superuser is not offered Deactivate on their own record. own := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken, nil) for _, name := range recordActionNames(own) { if name == "deactivate" { t.Fatal("deactivate offered on the editor's own record") } } // Delete is a soft delete; the row lists as deleted and restores (D-10). gone, _ := env.admin(t, "life-gone", false, "") env.expect(t, http.StatusOK, http.MethodDelete, fmt.Sprintf("%s/%d", usersPath, gone.ID), rootToken, nil) stored := env.storedUser(t, gone.ID) if !stored.DeletedAt.Valid || stored.TokensValidAfter == nil { t.Fatalf("deleted admin = %+v", stored) } hint := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/hint?id=%d", usersPath, gone.ID), rootToken, nil) if !strings.Contains(fmt.Sprint(hint["data"]), "This account has been deleted") { t.Fatalf("deleted admin hint = %v", hint["data"]) } list := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, rootToken, nil) if !listIDs(t, list)[gone.ID] || fmt.Sprint(rowStates(list, gone.ID)) != "[deleted]" { t.Fatalf("deleted admin row states = %v", rowStates(list, gone.ID)) } env.expect(t, http.StatusOK, http.MethodPost, fmt.Sprintf("%s/%d/actions/restore", usersPath, gone.ID), rootToken, map[string]any{}) list = env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, rootToken, nil) if !listIDs(t, list)[gone.ID] || len(rowStates(list, gone.ID)) != 0 { t.Fatalf("restored admin row states = %v", rowStates(list, gone.ID)) } hidden := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp+"&filter[trashed]=only", rootToken, nil) if listIDs(t, hidden)[gone.ID] { t.Fatal("the trashed=only filter lists a restored admin") } // The editor cannot delete their own account (D-08). env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken, nil, "your own account") // A bulk deactivate that selects the editor's own row is refused as a // whole: the other row stays activated too. other, _ := env.admin(t, "life-other", false, "") env.expectRefusal(t, http.StatusForbidden, http.MethodPost, usersPath+"/bulk/deactivate", rootToken, map[string]any{"ids": []uint{other.ID, root.ID}}, "deactivate your own account") if !env.storedUser(t, other.ID).IsActivated || !env.storedUser(t, root.ID).IsActivated { t.Fatal("a refused bulk deactivate changed a row") } // Bulk activate brings the deactivated target back without a new cutoff. before := env.storedUser(t, target.ID).TokensValidAfter result := env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk/activate", rootToken, map[string]any{"ids": []uint{target.ID, other.ID}}) if data, _ := result["data"].(map[string]any); data["affected"] != float64(1) { t.Fatalf("bulk activate result = %v", result) } if got := env.storedUser(t, target.ID); !got.IsActivated || got.TokensValidAfter == nil || !got.TokensValidAfter.Equal(*before) { t.Fatalf("activated target = %+v", got) } } func TestRolesSmoke(t *testing.T) { env := newScreensEnv(t) _, rootToken := env.admin(t, "roles-root", true, "") holder, _ := env.admin(t, "roles-holder", false, "") name := "Editors " + env.stamp created := env.expect(t, http.StatusCreated, http.MethodPost, rolesPath, rootToken, map[string]any{ "name": name, "code": "editors-" + env.stamp, "description": "Edit things", "permissions": map[string]any{"acme.demo.access": 1}, }) data, _ := created["data"].(map[string]any) roleID := uint(data["id"].(float64)) var stored cabana.BackendUserRole if err := env.gdb.First(&stored, roleID).Error; err != nil { t.Fatal(err) } if stored.Permissions != `{"acme.demo.access":1}` || stored.IsSystem { t.Fatalf("created role = %+v", stored) } // A code another role uses is refused on the field. details := env.expectRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, rolesPath, rootToken, map[string]any{"name": "Other " + env.stamp, "code": "editors-" + env.stamp}, "") if !strings.Contains(fmt.Sprint(details["code"]), "already used") { t.Fatalf("taken code details = %v", details) } // Assign it through the Administrators form, then the role cannot go. env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, holder.ID), rootToken, map[string]any{"login": holder.Login, "email": holder.Email, "role": roleID}) if got := env.storedUser(t, holder.ID); got.RoleID == nil || *got.RoleID != roleID { t.Fatalf("assigned role = %v", got.RoleID) } env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, fmt.Sprintf("%s/%d", rolesPath, roleID), rootToken, nil, "“"+name+"” is assigned to 1 administrator.") var developer cabana.BackendUserRole if err := env.gdb.Where("code = ?", "developer").First(&developer).Error; err != nil { t.Fatal(err) } env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, fmt.Sprintf("%s/%d", rolesPath, developer.ID), rootToken, nil, "System roles cannot be deleted.") details = env.expectRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, fmt.Sprintf("%s/%d", rolesPath, developer.ID), rootToken, map[string]any{"name": developer.Name, "code": "developer-renamed"}, "") if !strings.Contains(fmt.Sprint(details["code"]), "cannot be changed") { t.Fatalf("system code details = %v", details) } // Name and description of a system role stay editable. env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", rolesPath, developer.ID), rootToken, map[string]any{"name": developer.Name, "code": developer.Code, "description": developer.Description}) settings := env.expect(t, http.StatusOK, http.MethodGet, "/settings", rootToken, nil) var codes []string entries, _ := settings["data"].([]any) for _, entry := range entries { item, _ := entry.(map[string]any) if code, _ := item["code"].(string); code == "administrators" || code == "roles" { codes = append(codes, code) } } if fmt.Sprint(codes) != "[administrators roles]" { t.Fatalf("superuser settings order = %v", codes) } // An unused role is deleted. env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, holder.ID), rootToken, map[string]any{"login": holder.Login, "email": holder.Email, "role": developer.ID}) env.expect(t, http.StatusOK, http.MethodDelete, fmt.Sprintf("%s/%d", rolesPath, roleID), rootToken, nil) }