diff --git a/AGENTS.md b/AGENTS.md index b376fba..6433394 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,8 @@ A **Puavo-specific** Nextcloud app. Lets users with `puavoEduPersonAffiliation=t Read [`DEVELOPMENT.md`](DEVELOPMENT.md) for the local dev environment (Docker Compose, user_ldap setup, sample fixtures). +Read [`SHARING-INTERNALS.md`](SHARING-INTERNALS.md) for the Nextcloud code paths this app hooks into: how the sharee picker is answered, why the share check is a separate non-extensible mechanism, and the CalDAV asymmetry. + ## Local commands | Command | What it does | @@ -21,7 +23,7 @@ Read [`DEVELOPMENT.md`](DEVELOPMENT.md) for the local dev environment (Docker Co | `composer psalm` | Strict static analysis. Expect zero errors. | | `composer cs:check` | php-cs-fixer dry-run | | `composer cs:fix` | Apply style fixes | -| `make test` | PHPUnit inside the `master_stable33_1` container (override `test_container=` for stable31/32) | +| `make test` | PHPUnit inside the container named by `test_container=` in the `Makefile`; check `docker ps` and override on the command line | | `make` | Build `build/groupsharemachine.tar.gz` | | `make sign` | Same but signs via `occ integrity:sign-app` (cert in `~/.nextcloud/certificates/`) | @@ -48,7 +50,10 @@ The MySQL primary-key naming gotcha: explicit names like `gsm_groups_pk` are req Two app-owned tables in NC's main DB; both populated entirely from LDAP by `Service\LdapSync`. -- `groupsharemachine_groups (gid PK, group_type, school_name, school_dn)` — class groups (with their school for picker labels and scoping). +- `groupsharemachine_groups (gid PK, group_type, school_name, school_dn, display_name, abbreviation)` — class groups (with their school for picker labels and scoping). `display_name` exists to be searched: the `gid` of an LDAP group is frozen at the name it had when user_ldap first mapped it, so a group renamed later is unfindable by the name the picker shows. `searchEntriesInner()` matches **`display_name` only** — the name the picker puts on screen — and deliberately neither of the other two name columns: + + - **not `gid`**, because after a year rollover (`4. class` renamed to `5. class`, a new `4. class` created with gid `4. class_2`) the frozen gid is another group's current name, so matching it would offer the wrong cohort. It is used only as a fallback while `display_name` is still NULL, i.e. before the first sync after the upgrade. + - **not `abbreviation`** (the LDAP `cn`), because it never appears in the label. A hit on it is a result the teacher cannot account for, and it is redundant whenever it overlaps the display name. In puavo it is also often frozen at the group's original year (`cn: 1kl` on a group displayed `5. klasse`), giving it the same staleness as the gid. The column is still synced, so re-enabling the match or showing it in the label needs no migration. - `groupsharemachine_teachers (uid, school_dn) — composite PK` — one row per (teacher, school) authorisation. Multi-school teachers have multiple rows. The backend rejects any share where `getSchoolDn($gid)` is missing OR the (uid, school_dn) pair isn't in the teachers table. The picker only surfaces class groups whose school is in the searcher's school set. **Don't add "fallback" logic that lets unscoped rows through** — that would re-introduce cross-school leakage. @@ -99,7 +104,8 @@ Nextcloud uses two **distinct** code paths for a group share — the picker (aut └──▶ TeacherClassSearchPlugin (this app) if searcher has any (uid, school_dn) rows: SELECT … FROM oc_groupsharemachine_groups - WHERE gid LIKE %search% AND school_dn IN () + WHERE display_name LIKE %search% ← never gid, never abbreviation + AND school_dn IN () contribute each as SearchResultType('groups') (NOT filtered by GroupPlugin's user-groups rule — different plugin) ``` @@ -117,7 +123,9 @@ Nextcloud uses two **distinct** code paths for a group share — the picker (aut ## Testing -`make test` runs PHPUnit inside the dev container because mapper tests extend `Test\TestCase` from NC core. From the host alone they can't load the NC bootstrap. The test fakes for `OCA\User_LDAP\Group_Proxy` / `User_Proxy` / `Access` are anonymous classes inside `tests/Unit/Service/LdapSyncTest.php`; extend the existing structure when adding new sync behaviour. +`make test` runs PHPUnit inside the dev container because mapper tests extend `Test\TestCase` from NC core. From the host alone they can't load the NC bootstrap. + +**`make test` wipes the instance it runs against.** Core's `TestCase::tearDownAfterClass()` deletes every directory in the data dir (all user homes, `appdata_*`) and empties `oc_share` / `oc_storages` / `oc_filecache`; our mapper test truncates both app tables. It assumes a throwaway instance. Never point `test_container=` at an instance someone is browsing — the symptom is `NotFoundException: The root directory of the user's files is missing` at the next login, plus an empty share picker. Recovery is in `DEVELOPMENT.md`. The test fakes for `OCA\User_LDAP\Group_Proxy` / `User_Proxy` / `Access` are anonymous classes inside `tests/Unit/Service/LdapSyncTest.php`; extend the existing structure when adding new sync behaviour. ## Diagnostic diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 88a454b..206b173 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -169,8 +169,11 @@ The app relies on real LDAP-synced users and groups — there's no pure-Nextclou |---|---| | `dev/ldap/schema.ldif` | Adds minimal puavo schema (`puavoEduPerson`, `puavoEduGroup` aux objectclasses; `puavoId`, `puavoEduGroupType`, `puavoEduPersonAffiliation`, `puavoSchool` attributes) under cn=config. | | `dev/ldap/seed.ldif` | Two schools (Alpha, Beta), five users (alice/bob/charlie/diana/erik) and four class groups, designed to exercise single-school, multi-school, and cross-school-denial cases. | -| `dev/ldap/apply.sh` | `ldapadd`s schema + seed into the running `master_ldap_1` container. Idempotent — re-running just logs "already exists" for entries already there. | +| `dev/ldap/apply.sh` | `ldapadd`s schema + seed into the running LDAP container. Idempotent — re-running just logs "already exists" for entries already there. | | `dev/ldap/use-docker.sh` | Reconfigures NC's `user_ldap` to bind to the docker LDAP (`ldap:389`, anonymous bind allowed for admin). | +| `dev/ldap/rename-group.sh` | Changes a seed group's `displayName`, to reproduce the stale-gid condition that broke class-group search — see [Reproducing the renamed-group bug](#reproducing-the-renamed-group-bug). | + +All three find their container via `docker ps` rather than a hardcoded name, and re-invoke docker through `sudo` if the socket isn't reachable directly. Override with `LDAP_CONTAINER=` / `NC_CONTAINER=`, or point at another server with `NC_SERVICE=stable34`. ### One-time setup @@ -196,18 +199,67 @@ done ### Test accounts (passwords match the uid) +**Log in with the email address, not the bare uid.** `nextcloud-docker-dev` seeds its own +Database accounts called `alice` and `bob`, and Nextcloud consults the Database backend +before user_ldap — so `alice` / `alice` signs you in as the *local* account, which has no +teacher rows and therefore sees no class groups in the picker. The symptom is a share +dialog that finds nothing, with everything else apparently configured correctly. Check +with `occ user:info `: `backend: Database` means you are on the wrong account. + | Login | NC uid | Role | School(s) | Use case | |---|---|---|---|---| -| `alice` | `100001` | teacher | Alpha | single-school teacher | -| `bob` | `100002` | teacher | Alpha + Beta | multi-school teacher | -| `charlie` | `100003` | teacher | Beta | single-school teacher | -| `diana` | `100004` | student | Alpha | non-teacher, real-LDAP-member of `1A` | -| `erik` | `100005` | student | Beta | non-teacher | +| `alice@example.test` | `100001` | teacher | Alpha | single-school teacher | +| `bob@example.test` | `100002` | teacher | Alpha + Beta | multi-school teacher | +| `charlie@example.test` | `100003` | teacher | Beta | single-school teacher | +| `diana@example.test` | `100004` | student | Alpha | non-teacher, real-LDAP-member of `1A` | +| `erik@example.test` | `100005` | student | Beta | non-teacher | + +Email login works because `dev/ldap/use-docker.sh` sets `ldapLoginFilter` to +`(&(objectClass=puavoEduPerson)(|(uid=%uid)(mail=%uid)))`. On an instance configured +before that change, apply it with: + +```bash +sudo docker exec -u www-data master-stable33-1 php occ ldap:set-config s01 \ + ldapLoginFilter '(&(objectClass=puavoEduPerson)(|(uid=%uid)(mail=%uid)))' +sudo docker exec -u www-data master-stable33-1 php occ ldap:set-config s01 ldapLoginFilterMode 1 +``` The class groups `1A` (Alpha) and `1A_2` (Beta — collision suffix added by user_ldap because both have `displayName: 1A`) intentionally share a display name to exercise the picker's school-disambiguation labels. +### Reproducing the renamed-group bug + +Nextcloud has no stable internal id for groups: the gid is whatever `ldapGroupDisplayName` held when user_ldap first mapped the group, and it never changes afterwards. Rename the group upstream and Nextcloud shows the new name while every lookup still uses the old gid — which is why searching the share dialog for the name on screen used to return nothing. + +```bash +bash dev/ldap/apply.sh # seed: group 300001 is "1A" +ncocc stable33 groupsharemachine:sync # gid frozen as "1A" + +bash dev/ldap/rename-group.sh 300001 'Klasse 1A' +ncocc stable33 groupsharemachine:sync # display_name now "Klasse 1A" +``` + +Verify both halves are stored: + +```bash +sudo docker exec -t master-database-mysql-1 mysql -uroot -pnextcloud stable33 -e \ + "SELECT gid, display_name, abbreviation FROM oc_groupsharemachine_groups;" +``` + +Expect `gid = 1A` alongside `display_name = Klasse 1A`. Then log in as `alice@example.test` and type "Klasse" in a share dialog — the group must appear. Before the fix, "Klasse" found nothing at all, because only the frozen gid was searched. Searching the abbreviation (`alpha-1a`) must **not** find it: the column is synced but not searched, because it never appears in the picker label. + +Note that typing `1A` still finds this group, since `1A` is a substring of `Klasse 1A` — the match is on the display name, not on the gid. To watch the gid genuinely drop out of the search, rename to a name that shares nothing with the old one: + +```bash +bash dev/ldap/rename-group.sh 300001 'Klasse Eins' +ncocc stable33 groupsharemachine:sync +``` + +Now `1A` must return nothing for `alice@example.test` even though the row's gid is still literally `1A`, while `Eins` finds it. That is the behaviour the year-rollover case depends on: when `4. class` is renamed to `5. class` and a new `4. class` appears under gid `4. class_2`, a search for `4. class` has to return the new cohort only, never the group whose stale gid happens to spell it. + ### Expected matrix +With the seed exactly as shipped (no rename applied): + ``` User Type "1A" in share dialog → picker shows ───────────────────────────────────────────────────── @@ -217,6 +269,8 @@ charlie 1A (Beta School) diana 1A (real LDAP membership; app contributes nothing) ``` +After `rename-group.sh 300001 'Klasse 1A'` the Alpha rows are labelled `Klasse 1A (Alpha School)`, because the picker labels with the group's current display name. Logins are the email addresses — see [Test accounts](#test-accounts-passwords-match-the-uid). + ### Inspect ```bash @@ -264,12 +318,14 @@ composer rector # apply rector rules, then cs:fix make test # phpunit inside the stable33 container ``` +> **`make test` is destructive to the target instance.** It deletes every user home directory and empties `oc_share` / `oc_storages` / `oc_filecache` plus both app tables — see [the troubleshooting note](#never-run-the-tests-against-an-instance-you-are-using). Use a container you are not browsing in. + `composer psalm` runs at the configured `errorLevel` — expect zero errors on this branch. -`make test` shells into `master_stable33_1` and runs `vendor/bin/phpunit -c tests/phpunit.xml`. The mapper tests need NC's bootstrap (and a real DB), so they only work inside the container — running phpunit from the host won't load `Test\TestCase`. To target a different stable version, override `test_container=`: +`make test` shells into `master-stable33-1` and runs `vendor/bin/phpunit -c tests/phpunit.xml`. The mapper tests need NC's bootstrap (and a real DB), so they only work inside the container — running phpunit from the host won't load `Test\TestCase`. To target a different stable version, override `test_container=`: ```bash -make test test_container=master_stable32_1 +make test test_container=master-stable32-1 ``` ## 9. Building a release tarball @@ -289,7 +345,7 @@ make sign docker_container=master-stable33-1 **App not visible in `app:list`** — the bind mount must point to a real directory. See section 2 about `docker-compose.override.yml`. -**`Class OCA\GroupShareMachine\... not found`** — `composer install` was not run in this repo, or `appinfo/info.xml` declares a namespace that doesn't match the PSR-4 autoload entry. Check `composer.json` and run `composer dump-autoload`. +**`Class OCA\GroupShareMachine\... not found`** — most often the app is **not enabled in the container you are testing against**. `AppManager::loadApps()` registers an app's PSR-4 path only for enabled apps, and `vendor/bin/phpunit` does not load this repo's own `vendor/autoload.php`, so an unenabled app has no autoloader at all. Fix with `occ app:enable groupsharemachine` in that container (add `--force` when the container's Nextcloud is newer than `max-version` in `appinfo/info.xml`). Failing that, `composer install` was not run in this repo, or `appinfo/info.xml` declares a namespace that doesn't match the PSR-4 autoload entry — check `composer.json` and run `composer dump-autoload`. **Teacher sees no class groups in the share picker** — Run `occ groupsharemachine:diagnose `. It tells you whether the user is in the teachers table, whether the group is in the groups table, and what the backend would do. If either table is empty, run `occ groupsharemachine:sync`; if it still misses entries, check that user_ldap's Base User / Group Tree and Group-Member association are set, and that `puavoEduPersonAffiliation` / `puavoEduGroupType` exist on the corresponding LDAP entries. @@ -297,6 +353,52 @@ make sign docker_container=master-stable33-1 **`Permission denied` writing to `data/shared/sign`** — the dev container runs as `www-data` (uid 33). The `make sign` recipe `chmod -R a+rwX`'s the sign dir before invoking `occ`; if you ran it once as root the leftover files may need `sudo rm -rf data/shared/sign` to clean up. +**`OCP\Files\NotFoundException: The root directory of the user's files is missing`** — almost always means **`make test` has been run against the instance you are browsing**. See [Never run the tests against an instance you are using](#never-run-the-tests-against-an-instance-you-are-using) for why. + +The user's home exists on disk but has no `files/` subdirectory, so `OC_Helper::getStorageInfo()` bails out and every page of the Files app 500s — the login path sees `data//` already there and never re-runs skeleton setup. Recreate the directory and re-scan, in the container: + +```bash +sudo docker exec master-stable33-1 bash -c ' + mkdir -p /var/www/html/data//files && + cp -rn /skeleton/. /var/www/html/data//files/ && + chown -R www-data:www-data /var/www/html/data/' +sudo docker exec -u www-data master-stable33-1 php occ files:scan +sudo docker exec -u www-data master-stable33-1 php occ groupsharemachine:sync +``` + +`` is the Nextcloud uid, not the login name — for LDAP accounts that is the puavoId (`100001`), not `alice`. The sync is needed because the same test run empties the app tables. + +Copy from `/skeleton` (what `skeletondirectory` points at in this compose setup), not `core/skeleton`, which only holds `welcome.txt`. To find every account in this state rather than guessing from the error page, list the home storages that have no `files` node: + +```sql +SELECT s.id FROM oc_storages s + WHERE s.id LIKE 'home::%' + AND NOT EXISTS (SELECT 1 FROM oc_filecache f + WHERE f.storage = s.numeric_id AND f.path = 'files'); +``` + +Accounts that have never logged in have no storage row at all and provision normally, so they need no repair. + +**Never run the tests against an instance you are using** — the mapper tests extend `Test\TestCase` from Nextcloud core, whose `tearDownAfterClass()` cleans up after itself on the assumption that it owns a throwaway instance (`/var/www/html/tests/lib/TestCase.php`): + +```php +self::tearDownAfterClassCleanShares($queryBuilder); // DELETE FROM oc_share +self::tearDownAfterClassCleanStorages($queryBuilder); // DELETE FROM oc_storages +self::tearDownAfterClassCleanFileCache($queryBuilder); // DELETE FROM oc_filecache +self::tearDownAfterClassCleanStrayDataFiles($dataDir); // rm -rf everything in data/ +``` + +`tearDownAfterClassCleanStrayDataFiles()` keeps only `nextcloud.log`, `audit.log`, `owncloud.db` and `.ocdata`, and recursively deletes every other directory in the data directory — all user homes and `appdata_*` included. On top of that `ClassGroupMapperTest` truncates both app tables in `setUp()`/`tearDown()`. + +So one `make test` run leaves the instance with no user home directories, an empty filecache and empty app tables. Nothing warns you; the damage only shows up at the next login as the 500 above, and as a share picker that finds no groups. + +Point `test_container=` at a stable container you do not browse in, and keep manual testing on another one: + +```bash +make test test_container=master-stable32-1 # tests here +# browse http://stable33.local # manual testing there +``` + **Container name mismatch** — names are derived from `COMPOSE_PROJECT_NAME=master` in `.env` (e.g. `master-stable33-1`), but a container recreated in place keeps its original name, so a stack can end up with mixed separators. Run `docker ps --format '{{.Names}}'` to see what you actually have, and override `docker_container=` / `test_container=` on the `make` command line. **`Could not find a valid Nextcloud source in /var/www/html`** — `workspace/stableNN/` is empty. See section 1 — `bootstrap.sh` doesn't clone the stable branches, you have to do it yourself. diff --git a/Makefile b/Makefile index 6813b69..351b76e 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,11 @@ app_name=groupsharemachine build_dir=$(CURDIR)/build sign_dir=$(build_dir)/sign cert_dir=$(HOME)/.nextcloud/certificates -docker_container=master_nextcloud_1 -test_container=master_stable33_1 +# Container names come from COMPOSE_PROJECT_NAME=master in nextcloud-docker-dev. +# Check `docker ps --format '{{.Names}}'` and override on the command line if +# these don't match — a container recreated in place keeps its original name. +docker_container=master-stable33-1 +test_container=master-stable33-1 container_app_path=/var/www/html/apps-shared/$(app_name) shared_dir=$(HOME)/dev/nextcloud/nextcloud-docker-dev/data/shared @@ -48,7 +51,7 @@ sign: package .PHONY: test test: docker exec -u www-data $(test_container) bash -c \ - "cd $(container_app_path) && vendor/bin/phpunit -c tests/phpunit.xml --no-coverage" + "cd $(container_app_path) && vendor/bin/phpunit -c tests/phpunit.xml --no-coverage --display-warnings" .PHONY: psalm psalm: diff --git a/README.md b/README.md index 8677756..2a3c303 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,8 @@ Pre-requisite: a Puavo LDAP populated with at least one teacher (`puavoEduPerson For contributor-facing details — the two custom tables (`oc_groupsharemachine_groups`, `oc_groupsharemachine_teachers`), the share-check and picker code paths we hook into, sync mechanics, and conventions — see [`AGENTS.md`](AGENTS.md). +For the Nextcloud side of it — how the share dialog is answered, why the picker and the share check are separate mechanisms, and why calendars behave differently — see [`SHARING-INTERNALS.md`](SHARING-INTERNALS.md). + ## Releasing a new version 1. Update the version in `appinfo/info.xml` diff --git a/SHARING-INTERNALS.md b/SHARING-INTERNALS.md new file mode 100644 index 0000000..485c5af --- /dev/null +++ b/SHARING-INTERNALS.md @@ -0,0 +1,161 @@ + + +# How Nextcloud sharing works, and where this app hooks in + +Notes on the Nextcloud code paths behind the share dialog, written while +debugging this app. The short version: **the picker and the share check are +two different mechanisms**, only one of them is extensible, and calendars use +a third that ignores both. + +Line references are from **Nextcloud 33** (`stable33`). They drift between +releases; the class and method names are the stable part. + +## 1. Who answers the share dialog + +Typing in the sharee field issues one OCS request per keystroke (debounced): + +``` +GET /ocs/v2.php/apps/files_sharing/api/v1/sharees?search=klasse&itemType=file + └─ ShareesAPIController::search() apps/files_sharing/lib/Controller/ + │ enforces sharing.minSearchStringLength (default 0) + └─ OC\Collaboration\Collaborators\Search::search() + ├─ UserPlugin core + ├─ GroupPlugin core + ├─ MailPlugin / RemotePlugin / LookupPlugin + └─ TeacherClassSearchPlugin this app +``` + +Every plugin writes into one shared `ISearchResult`, which keeps two ranked +buckets — `exact` and `wide`. + +### Where group results come from + +Core's `GroupPlugin` calls `IGroupManager::search()`, which fans out to every +registered group backend: + +| Backend | Storage | How it searches | +|---|---|---| +| `Database` | SQL `oc_groups` | `LIKE` on gid / displayname | +| `user_ldap` `Group_LDAP::getGroups()` | **live LDAP** | `ldapGroupFilter` AND `getFilterPartForGroupSearch()`, i.e. the attributes in `ldapAttributesForGroupSearch` (or the display-name attribute). Results cached per `getGroups---` for `ldapCacheTTL`, default 600 s | +| `TeacherClassMembership` (this app) | — | returns `[]` on purpose | + +So core *does* query LDAP, and LDAP *would* match on display name. But when +`shareapi_only_share_with_group_members` is on, `GroupPlugin` then throws most +of it away: + +```php +// lib/private/Collaboration/Collaborators/GroupPlugin.php:61-70 +$userGroups = $this->groupManager->getUserGroups($this->userSession->getUser()); +$groupIds = array_intersect($groupIds, $userGroups); +``` + +Class groups are not in a teacher's group list — this app keeps them out +deliberately, so teachers neither see them in their profile nor auto-receive +shares aimed at them. Consequence: **core's own search can never surface a +class group to a teacher.** That is what `TeacherClassSearchPlugin` exists for, +and it answers from the local `oc_groupsharemachine_groups` snapshot, never +from LDAP. + +## 2. The share check — not extensible + +There is no plugin interface for "may X share with Y". The rule is hardcoded: + +```php +// lib/private/Share20/Manager.php:517-526 (groupCreateChecks) +$sharedWith = $this->groupManager->get($share->getSharedWith()); +if (is_null($sharedWith) || in_array(...) || !$sharedWith->inGroup($sharedBy)) { + throw new \Exception('Sharing is only allowed within your own groups'); +} +``` + +`IGroup::inGroup()` is OR-aggregated across all backends that claimed +`groupExists($gid)`, so a backend can answer "yes, this user is a member" for +sharing purposes without the group appearing anywhere else. That is the second +half of this app: + +```php +// lib/AppInfo/Application.php (boot) +$groupManager->addBackend($backend); // makes the share pass +$collaboratorSearch->registerPlugin([...]); // makes the group findable +``` + +**Both halves are required.** The plugin alone shows a group the share check +then rejects; the backend alone accepts a share for a group nobody can find. + +## 3. Calendars (CalDAV) — a third mechanism + +Calendar sharing does not go through `Share20\Manager` at all, and it never +calls `inGroup()`. It intersects the user's group list instead: + +```php +// apps/dav/lib/DAV/GroupPrincipalBackend.php:173-180 (searchPrincipals) +// apps/dav/lib/DAV/GroupPrincipalBackend.php:252-259 (findByUri) +if ($this->shareManager->shareWithGroupMembersOnly()) { + $restrictGroups = $this->groupManager->getUserGroupIds($user); +} +... +if ($restrictGroups !== false && !\in_array($name, $restrictGroups, true)) { + return null; +} +``` + +So virtualised membership is invisible to CalDAV: the class group is filtered +out of the calendar share picker (`searchPrincipals`) and the share itself is +refused (`findByUri`). Two further notes: + +- `shareWithGroupMembersOnlyExcludeGroupsList()` — the official escape hatch, + honoured by files sharing (`Manager.php:461`, `:522`) — has **no references + anywhere in `apps/dav`**. Exempting a group fixes files and not calendars. +- `GroupPrincipalBackend` is constructed inside the `dav` app, so an app + container cannot decorate or replace it. Fixing this properly means an + upstream change: have DAV ask `inGroup()`/`isInGroup()` instead of + intersecting `getUserGroupIds()`. Behaviour is identical for ordinary + backends and starts working for virtual ones. + +## 4. What a picker plugin can actually do + +`ISearchPlugin` is one method — `search($search, $limit, $offset, ISearchResult $result)` — +and `ISearchResult` allows more than appending: + +| Method | Effect | +|---|---| +| `addResultSet($type, $wide, $exact)` | add hits to either bucket | +| `hasResult($type, $id)` | skip what another plugin already returned | +| `removeCollaboratorResult($type, $id)` | delete another plugin's hit | +| `unsetResult($type)` | drop an entire result type | +| `markExactIdMatch($type)` | force the "exact match" treatment | + +Plugins run in registration order, so a late plugin can filter earlier ones, +but not the reverse. Core's own plugins use the same interface — an app plugin +is not second class. + +Other sharing extension points, for orientation: `OCP\Share\IShareProvider` +plus the provider factory (invent a new share type and own its storage and +rules), and `IRegistrationContext::registerPublicShareTemplateProvider()` (the +only sharing-related entry in `IRegistrationContext`, for public link pages). + +## 5. Gotchas worth remembering + +- **Group gids are frozen.** Nextcloud has no stable internal id for groups. + For LDAP groups the gid is whatever `ldapGroupDisplayName` held when + `user_ldap` first mapped the group, and a later rename updates only the + display name — `oc_ldap_group_mapping` keeps the original. Anything that + searches or stores gids must not assume the gid equals the visible name; + this app stores `display_name` alongside it for exactly that reason, and + searches that column rather than the gid. +- **`groupExists()` gates `inGroup()`.** A backend whose `groupExists()` + returns `false` never gets asked about membership, and the share is denied + with no clue as to why. +- **The label and the query can disagree.** The picker renders + `IGroup::getDisplayName()` (live, via the LDAP backend's cache) while a + plugin may match against its own stored copy — a group can therefore be + displayed correctly and still be unfindable. +- **`sharing.minSearchStringLength`** silently suppresses short queries before + any plugin runs. +- **user_ldap caches** group searches, memberships and display names for + `ldapCacheTTL` (default 600 s), which makes search behaviour look + intermittent while testing. Saving any LDAP config clears the cache + (`Connection::saveConfiguration()` → `clearCache()`). diff --git a/appinfo/info.xml b/appinfo/info.xml index b3706dc..75eebe1 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -13,7 +13,7 @@ Puavo-specific Nextcloud app. Lets teachers share files with class groups via Ne This app is only useful in deployments that synchronise from a Puavo LDAP instance via user_ldap, with the puavoEdu* schema in place. It is not a general-purpose sharing helper. ]]> - 1.0.0 + 1.0.1 AGPL-3.0-or-later Opinsys Oy GroupShareMachine diff --git a/dev/ldap/apply.sh b/dev/ldap/apply.sh index 1e8a18c..0b99655 100644 --- a/dev/ldap/apply.sh +++ b/dev/ldap/apply.sh @@ -8,7 +8,6 @@ set -euo pipefail -LDAP_CONTAINER="${LDAP_CONTAINER:-master_ldap_1}" ADMIN_DN="cn=admin,dc=planetexpress,dc=com" ADMIN_PW="admin" CONFIG_DN="cn=admin,cn=config" @@ -16,12 +15,24 @@ CONFIG_PW="config" DEV_DIR="$(cd "$(dirname "$0")" && pwd)" -if ! docker ps --format '{{.Names}}' | grep -qx "$LDAP_CONTAINER"; then - echo "Container $LDAP_CONTAINER is not running." - echo "Start it from nextcloud-docker-dev with: docker-compose up -d ldap" +# Fall back to sudo when the current user can't reach the docker socket. +DOCKER="${DOCKER:-docker}" +if ! $DOCKER info >/dev/null 2>&1 && command -v sudo >/dev/null; then + DOCKER="sudo docker" +fi + +# Container names depend on which Compose version created them, so look the +# name up instead of hardcoding a separator. Override with LDAP_CONTAINER=. +LDAP_CONTAINER="${LDAP_CONTAINER:-$($DOCKER ps --format '{{.Names}}' | grep -E '(^|[-_])ldap([-_]|$)' | head -1)}" + +if [ -z "$LDAP_CONTAINER" ] || ! $DOCKER ps --format '{{.Names}}' | grep -qx "$LDAP_CONTAINER"; then + echo "No running LDAP container found${LDAP_CONTAINER:+ (looked for '$LDAP_CONTAINER')}." + echo "Start it from nextcloud-docker-dev with: docker compose up -d ldap" exit 1 fi +echo "==> Using LDAP container: $LDAP_CONTAINER" + ldap_add() { local description="$1" local user="$2" @@ -30,7 +41,7 @@ ldap_add() { echo echo "==> $description ($file)" # -c continues on errors (so we tolerate "already exists") - docker exec -i "$LDAP_CONTAINER" ldapadd -x -H ldap://localhost -D "$user" -w "$pw" -c < "$DEV_DIR/$file" || true + $DOCKER exec -i "$LDAP_CONTAINER" ldapadd -x -H ldap://localhost -D "$user" -w "$pw" -c < "$DEV_DIR/$file" || true } ldap_add "Adding puavo schema to cn=config" "$CONFIG_DN" "$CONFIG_PW" "schema.ldif" @@ -38,5 +49,5 @@ ldap_add "Loading puavo-flavoured test data" "$ADMIN_DN" "$ADMIN_PW" "seed.l echo echo "==> Done. Verify with:" -echo " docker exec -i $LDAP_CONTAINER ldapsearch -x -D '$ADMIN_DN' -w '$ADMIN_PW' \\" +echo " $DOCKER exec -i $LDAP_CONTAINER ldapsearch -x -D '$ADMIN_DN' -w '$ADMIN_PW' \\" echo " -b 'ou=Puavo,dc=planetexpress,dc=com' '(objectclass=puavoEduPerson)' uid puavoEduPersonAffiliation puavoSchool" diff --git a/dev/ldap/rename-group.sh b/dev/ldap/rename-group.sh new file mode 100755 index 0000000..7eef5ea --- /dev/null +++ b/dev/ldap/rename-group.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Opinsys Oy +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Rename a seed group in the local docker LDAP, to reproduce the condition +# that broke class-group search in production: Nextcloud freezes a group's +# gid at the displayName it had when user_ldap first mapped it, so after a +# rename the gid and the visible name differ. +# +# dev/ldap/apply.sh # seed data +# occ groupsharemachine:sync # gid gets frozen as "1A" +# dev/ldap/rename-group.sh 300001 "Klasse 1A" +# occ groupsharemachine:sync # display_name becomes "Klasse 1A" +# +# Then search the share dialog for "Klasse": before the display_name column +# existed this found nothing, because only the stale gid was matched. +# +# Seed groups: 300001 alpha-1a (1A), 300002 alpha-2a (2A), +# 300003 beta-1a (1A), 300004 beta-math (Math (teaching group)) + +set -euo pipefail + +if [ $# -ne 2 ]; then + echo "usage: $(basename "$0") " >&2 + echo " e.g. $(basename "$0") 300001 'Klasse 1A'" >&2 + exit 2 +fi + +PUAVO_ID="$1" +NEW_NAME="$2" + +ADMIN_DN="cn=admin,dc=planetexpress,dc=com" +ADMIN_PW="admin" +GROUP_DN="puavoId=${PUAVO_ID},ou=Groups,ou=Puavo,dc=planetexpress,dc=com" + +# Fall back to sudo when the current user can't reach the docker socket. +DOCKER="${DOCKER:-docker}" +if ! $DOCKER info >/dev/null 2>&1 && command -v sudo >/dev/null; then + DOCKER="sudo docker" +fi + +LDAP_CONTAINER="${LDAP_CONTAINER:-$($DOCKER ps --format '{{.Names}}' | grep -E '(^|[-_])ldap([-_]|$)' | head -1)}" +if [ -z "$LDAP_CONTAINER" ]; then + echo "No running LDAP container found. Start it with: docker compose up -d ldap" >&2 + exit 1 +fi + +echo "==> $LDAP_CONTAINER: $GROUP_DN -> displayName: $NEW_NAME" + +$DOCKER exec -i "$LDAP_CONTAINER" ldapmodify -x -H ldap://localhost \ + -D "$ADMIN_DN" -w "$ADMIN_PW" < Done. Re-run 'occ groupsharemachine:sync', then check that the gid" +echo " still holds the old name while display_name holds the new one:" +echo " occ groupsharemachine:diagnose " diff --git a/dev/ldap/use-docker.sh b/dev/ldap/use-docker.sh index 351fd6a..fadacd6 100644 --- a/dev/ldap/use-docker.sh +++ b/dev/ldap/use-docker.sh @@ -8,11 +8,30 @@ set -euo pipefail -NC_CONTAINER="${NC_CONTAINER:-master_stable33_1}" PREFIX="${LDAP_PREFIX:-s01}" +NC_SERVICE="${NC_SERVICE:-stable33}" + +# Fall back to sudo when the current user can't reach the docker socket. +DOCKER="${DOCKER:-docker}" +if ! $DOCKER info >/dev/null 2>&1 && command -v sudo >/dev/null; then + DOCKER="sudo docker" +fi + +# Container names depend on which Compose version created them, so look the +# name up instead of hardcoding a separator. Override with NC_CONTAINER=, or +# pick a different server with NC_SERVICE=stable34. +NC_CONTAINER="${NC_CONTAINER:-$($DOCKER ps --format '{{.Names}}' | grep -E "(^|[-_])${NC_SERVICE}([-_]|$)" | head -1)}" + +if [ -z "$NC_CONTAINER" ]; then + echo "No running container found for service '$NC_SERVICE'." + echo "Start it with: docker compose up -d $NC_SERVICE" + exit 1 +fi + +echo "==> Using Nextcloud container: $NC_CONTAINER" run_occ() { - docker exec -u www-data "$NC_CONTAINER" php /var/www/html/occ "$@" + $DOCKER exec -u www-data "$NC_CONTAINER" php /var/www/html/occ "$@" } set_cfg() { @@ -37,10 +56,16 @@ set_cfg ldapBaseGroups 'ou=Groups,ou=Puavo,dc=planetexpress,dc=com' set_cfg ldapUserFilter '(objectClass=puavoEduPerson)' set_cfg ldapUserFilterObjectclass 'puavoEduPerson' set_cfg ldapUserFilterMode '1' -set_cfg ldapLoginFilter '(&(objectClass=puavoEduPerson)(uid=%uid))' -set_cfg ldapLoginFilterMode '0' +# Mail is accepted as a login name on purpose: the nextcloud-docker-dev stack +# ships Database accounts named alice and bob, and the Database backend is +# consulted before user_ldap, so logging in as plain "alice" lands on the local +# account instead of the LDAP teacher. Logging in as alice@example.test is +# unambiguous. Raw filter mode (1) because the assisted builder would overwrite +# this from the checkboxes below. +set_cfg ldapLoginFilter '(&(objectClass=puavoEduPerson)(|(uid=%uid)(mail=%uid)))' +set_cfg ldapLoginFilterMode '1' set_cfg ldapLoginFilterUsername '1' -set_cfg ldapLoginFilterEmail '0' +set_cfg ldapLoginFilterEmail '1' set_cfg ldapAttributesForUserSearch 'displayName;uid' set_cfg ldapGroupFilter '(&(objectClass=posixGroup)(puavoEduGroupType=*))' diff --git a/lib/Collaboration/TeacherClassSearchPlugin.php b/lib/Collaboration/TeacherClassSearchPlugin.php index c0278e0..fc75226 100644 --- a/lib/Collaboration/TeacherClassSearchPlugin.php +++ b/lib/Collaboration/TeacherClassSearchPlugin.php @@ -66,7 +66,10 @@ public function search($search, $limit, $offset, ISearchResult $searchResult): b 'shareWith' => $gid, ], ]; - if (strcasecmp($displayName, $search) === 0 || strcasecmp($gid, $search) === 0) { + // Only the visible name counts as an exact match. The gid is stale + // after a rename and may be another group's current name, so + // matching it here would rank an impostor above the real hit. + if (strcasecmp($displayName, $search) === 0) { $exact[] = $entry; } else { $wide[] = $entry; diff --git a/lib/Db/ClassGroupMapper.php b/lib/Db/ClassGroupMapper.php index 82ee1cb..a4d790c 100644 --- a/lib/Db/ClassGroupMapper.php +++ b/lib/Db/ClassGroupMapper.php @@ -66,6 +66,7 @@ public function searchGids(string $search, int $limit, int $offset): array { $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($search) . '%'), )); } + // Sort by what the picker actually shows, not by the frozen gid. $qb->orderBy('gid') ->setMaxResults($limit > 0 ? $limit : 200) ->setFirstResult($offset); @@ -84,7 +85,7 @@ public function searchGids(string $search, int $limit, int $offset): array { * Substring-match search returning gid + school metadata. Used by the * picker plugin to render labels with school disambiguation. * - * @return list + * @return list */ public function searchEntries(string $search, int $limit, int $offset): array { return $this->searchEntriesInner($search, null, $limit, $offset); @@ -95,7 +96,7 @@ public function searchEntries(string $search, int $limit, int $offset): array { * is in $schoolDns. Used by the picker to enforce per-school scoping. * * @param list $schoolDns - * @return list + * @return list */ public function searchEntriesForSchools(string $search, array $schoolDns, int $limit, int $offset): array { if ($schoolDns === []) { @@ -106,15 +107,37 @@ public function searchEntriesForSchools(string $search, array $schoolDns, int $l /** * @param ?list $schoolDns null = unrestricted - * @return list + * @return list */ private function searchEntriesInner(string $search, ?array $schoolDns, int $limit, int $offset): array { $qb = $this->db->getQueryBuilder(); - $qb->select('gid', 'school_name', 'school_dn')->from(self::TABLE); + $qb->select('gid', 'school_name', 'school_dn', 'display_name', 'abbreviation')->from(self::TABLE); if ($search !== '') { - $qb->where($qb->expr()->iLike( - 'gid', - $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($search) . '%'), + // Match only the name the picker shows. The two other names we + // store are deliberately not searched: + // + // - gid: frozen at the name the group had when user_ldap first + // mapped it, so after a rename it is stale — and on a year + // rollover ("4. class" becomes "5. class", a new "4. class" is + // created and gets gid "4. class_2") it is literally another + // group's current name. Matching it would answer a search for + // "4. class" with the cohort labelled "5. class". + // - abbreviation: never rendered in the picker label, so it can + // only ever contribute hits the teacher cannot account for. It + // adds nothing when it overlaps the display name, and when it + // does not it is typically a slug frozen at the group's original + // year (cn "1kl" on a group now displayed "5. klasse") — the same + // staleness as the gid, with the same potential to mislead. + $pattern = $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($search) . '%'); + $qb->where($qb->expr()->orX( + $qb->expr()->iLike('display_name', $pattern), + // Rows written before display_name existed, or by a sync that + // could not read the attribute: fall back to the gid so those + // groups stay findable until the next sync fills it in. + $qb->expr()->andX( + $qb->expr()->isNull('display_name'), + $qb->expr()->iLike('gid', $pattern), + ), )); } if ($schoolDns !== null) { @@ -123,7 +146,8 @@ private function searchEntriesInner(string $search, ?array $schoolDns, int $limi $qb->createNamedParameter($schoolDns, IQueryBuilder::PARAM_STR_ARRAY), )); } - $qb->orderBy('gid') + // Sort by what the picker actually shows, not by the frozen gid. + $qb->orderBy($qb->createFunction('COALESCE(display_name, gid)')) ->setMaxResults($limit > 0 ? $limit : 200) ->setFirstResult($offset); @@ -131,10 +155,12 @@ private function searchEntriesInner(string $search, ?array $schoolDns, int $limi $entries = []; while (($row = $result->fetch()) !== false) { $school = $row['school_name'] ?? null; + $displayName = $row['display_name'] ?? null; $entries[] = [ 'gid' => (string)$row['gid'], 'school_name' => $school === null ? null : (string)$school, 'school_dn' => (string)($row['school_dn'] ?? ''), + 'display_name' => $displayName === null ? null : (string)$displayName, ]; } $result->closeCursor(); @@ -174,12 +200,21 @@ public function getSchoolDn(string $gid): ?string { return (string)$value; } - public function upsert(string $gid, string $groupType, ?string $schoolName = null, string $schoolDn = ''): void { + public function upsert( + string $gid, + string $groupType, + ?string $schoolName = null, + string $schoolDn = '', + ?string $displayName = null, + ?string $abbreviation = null, + ): void { $qb = $this->db->getQueryBuilder(); $update = $qb->update(self::TABLE) ->set('group_type', $qb->createNamedParameter($groupType)) ->set('school_name', $qb->createNamedParameter($schoolName)) ->set('school_dn', $qb->createNamedParameter($schoolDn)) + ->set('display_name', $qb->createNamedParameter($displayName)) + ->set('abbreviation', $qb->createNamedParameter($abbreviation)) ->where($qb->expr()->eq('gid', $qb->createNamedParameter($gid))); $updated = $update->executeStatement(); @@ -191,6 +226,8 @@ public function upsert(string $gid, string $groupType, ?string $schoolName = nul 'group_type' => $insert->createNamedParameter($groupType), 'school_name' => $insert->createNamedParameter($schoolName), 'school_dn' => $insert->createNamedParameter($schoolDn), + 'display_name' => $insert->createNamedParameter($displayName), + 'abbreviation' => $insert->createNamedParameter($abbreviation), ]) ->executeStatement(); } diff --git a/lib/Migration/Version1001Date20260909120000.php b/lib/Migration/Version1001Date20260909120000.php new file mode 100644 index 0000000..7a18463 --- /dev/null +++ b/lib/Migration/Version1001Date20260909120000.php @@ -0,0 +1,64 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\GroupShareMachine\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds the two searchable name columns to groupsharemachine_groups. + * + * The picker used to match the search term against the gid only. For LDAP + * groups the gid is frozen at the value ldapGroupDisplayName had when the + * group was first mapped, so a group renamed in puavo keeps the old gid + * while Nextcloud shows the new name — and searching for the name you see + * matched nothing. + * + * - display_name: current ldapGroupDisplayName value, i.e. what the picker + * labels the group with. This is the only column the search matches. + * - abbreviation: the group's cn, which in puavo is the short slug ("nct"). + * Stored but not searched — see ClassGroupMapper::searchEntriesInner(). + * Kept so that making it searchable again, or surfacing it in the label, + * needs no further migration. + * + * Both are refreshed on every sync and left NULL until the next one runs. + */ +class Version1001Date20260909120000 extends SimpleMigrationStep { + + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('groupsharemachine_groups')) { + return null; + } + + $table = $schema->getTable('groupsharemachine_groups'); + $changed = false; + + if (!$table->hasColumn('display_name')) { + $table->addColumn('display_name', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $changed = true; + } + + if (!$table->hasColumn('abbreviation')) { + $table->addColumn('abbreviation', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $changed = true; + } + + return $changed ? $schema : null; + } +} diff --git a/lib/Service/LdapSync.php b/lib/Service/LdapSync.php index b49e413..bdbce26 100644 --- a/lib/Service/LdapSync.php +++ b/lib/Service/LdapSync.php @@ -30,6 +30,12 @@ class LdapSync { public const GROUP_TYPE_ATTR = 'puavoEduGroupType'; public const SCHOOL_ATTR = 'puavoSchool'; public const SCHOOL_NAME_ATTR = 'displayName'; + /** + * puavo puts the group's short slug in cn; teachers search by it. Lowercase + * already, unlike the camelCase attributes above, because LDAP records come + * back keyed by lowercased attribute name. + */ + public const GROUP_ABBREVIATION_ATTR = 'cn'; public const AFFILIATION_ATTR = 'puavoEduPersonAffiliation'; public const TEACHER_AFFILIATION = 'teacher'; @@ -73,13 +79,29 @@ private function syncGroups(): array { $this->orFilter(self::GROUP_TYPE_ATTR, self::ALLOWED_GROUP_TYPES), ]); + // Whatever user_ldap is configured to show as the group name. The gid is + // frozen at the value this attribute had when the group was first + // mapped, so on a renamed group the two differ and only this one matches + // what the teacher sees in the picker. + // + // Plain property read on purpose: user_ldap's Connection implements + // __get() but no __isset(), so `?? 'cn'` would evaluate isset() as false + // and always take the fallback, quietly discarding the real attribute. + $displayAttr = strtolower((string)($access->getConnection()->ldapGroupDisplayName ?: 'cn')); + $kept = []; $seen = 0; $offset = 0; /** @var array $schoolNameCache DN -> resolved displayName (or null) */ $schoolNameCache = []; do { - $records = $this->safeSearch($access, isUser: false, filter: $filter, offset: $offset); + $records = $this->safeSearch( + $access, + isUser: false, + filter: $filter, + offset: $offset, + extraAttrs: [$displayAttr, self::GROUP_ABBREVIATION_ATTR], + ); foreach ($records as $record) { $seen++; $dn = $record['dn'][0] ?? null; @@ -96,7 +118,16 @@ private function syncGroups(): array { if (is_string($schoolDn) && $schoolDn !== '') { $schoolName = $schoolNameCache[$schoolDn] ??= $this->resolveSchoolName($access, $schoolDn); } - $this->groupMapper->upsert($gid, $type, $schoolName, is_string($schoolDn) ? $schoolDn : ''); + $displayName = $record[$displayAttr][0] ?? null; + $abbreviation = $record[self::GROUP_ABBREVIATION_ATTR][0] ?? null; + $this->groupMapper->upsert( + $gid, + $type, + $schoolName, + is_string($schoolDn) ? $schoolDn : '', + is_string($displayName) ? $displayName : null, + is_string($abbreviation) ? $abbreviation : null, + ); $kept[] = $gid; } $offset += self::PAGE_SIZE; @@ -173,13 +204,15 @@ private function syncTeachers(): array { } /** + * @param list $extraAttrs additional (lowercased) attributes to request * @return array */ - private function safeSearch(object $access, bool $isUser, string $filter, int $offset): array { + private function safeSearch(object $access, bool $isUser, string $filter, int $offset, array $extraAttrs = []): array { try { $attrs = $isUser ? ['dn', strtolower(self::SCHOOL_ATTR)] : ['dn', strtolower(self::GROUP_TYPE_ATTR), strtolower(self::SCHOOL_ATTR)]; + $attrs = array_values(array_unique([...$attrs, ...$extraAttrs])); $records = $isUser ? $access->searchUsers($filter, $attrs, self::PAGE_SIZE, $offset) : $access->searchGroups($filter, $attrs, self::PAGE_SIZE, $offset); diff --git a/tests/Unit/Collaboration/TeacherClassSearchPluginTest.php b/tests/Unit/Collaboration/TeacherClassSearchPluginTest.php index 0fa31d7..25becae 100644 --- a/tests/Unit/Collaboration/TeacherClassSearchPluginTest.php +++ b/tests/Unit/Collaboration/TeacherClassSearchPluginTest.php @@ -188,10 +188,35 @@ public function testExactMatchGoesToExactBucket(): void { }), ); - // search term matches the gid exactly → exact bucket + // search term matches the display name exactly → exact bucket $this->plugin->search('class_alpha', 100, 0, $this->searchResult); } + public function testStaleGidMatchDoesNotCountAsExact(): void { + $teacher = $this->createMock(IUser::class); + $teacher->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($teacher); + $this->teacherMapper->method('schoolsForTeacher')->willReturn(['puavoId=1,ou=Groups']); + + // Renamed group: gid frozen at '4. class', now displayed as '5. class'. + $this->groupMapper->method('searchEntriesForSchools') + ->willReturn([['gid' => '4. class', 'school_name' => null]]); + $this->groupManager->method('get')->willReturn($this->mockGroupNamed('4. class', '5. class')); + $this->searchResult->method('hasResult')->willReturn(false); + + $this->searchResult->expects($this->once()) + ->method('addResultSet') + ->with( + $this->isInstanceOf(SearchResultType::class), + $this->callback(static fn (array $wide): bool => count($wide) === 1), + [], // exact — empty + ); + + // Typing the old name in full must not promote the group above a real + // match: that name belongs to the incoming cohort now. + $this->plugin->search('4. class', 100, 0, $this->searchResult); + } + public function testSkipsGroupsAlreadyInResult(): void { $teacher = $this->createMock(IUser::class); $teacher->method('getUID')->willReturn('alice'); @@ -256,9 +281,13 @@ public function testSkipsUnresolvableGroups(): void { } private function mockGroup(string $displayName): IGroup&MockObject { + return $this->mockGroupNamed($displayName, $displayName); + } + + private function mockGroupNamed(string $gid, string $displayName): IGroup&MockObject { $group = $this->createMock(IGroup::class); $group->method('getDisplayName')->willReturn($displayName); - $group->method('getGID')->willReturn($displayName); + $group->method('getGID')->willReturn($gid); return $group; } } diff --git a/tests/Unit/Db/ClassGroupMapperTest.php b/tests/Unit/Db/ClassGroupMapperTest.php index 72ff402..7301ac9 100644 --- a/tests/Unit/Db/ClassGroupMapperTest.php +++ b/tests/Unit/Db/ClassGroupMapperTest.php @@ -63,11 +63,80 @@ public function testSearchEntriesIncludesSchoolName(): void { usort($entries, static fn (array $a, array $b): int => strcmp($a['gid'], $b['gid'])); $this->assertSame([ - ['gid' => 'class_1a', 'school_name' => 'School A', 'school_dn' => 'puavoId=1,ou=Groups'], - ['gid' => 'class_1b', 'school_name' => null, 'school_dn' => ''], + ['gid' => 'class_1a', 'school_name' => 'School A', 'school_dn' => 'puavoId=1,ou=Groups', 'display_name' => null], + ['gid' => 'class_1b', 'school_name' => null, 'school_dn' => '', 'display_name' => null], ], $entries); } + public function testSearchMatchesOnlyTheVisibleName(): void { + // A group renamed after it was first synced: Nextcloud froze the gid at + // the old name ('grp_7'), while display name and cn carry the current + // values. Only the display name is searchable — it is the one of the + // three the picker actually puts on screen. + $this->mapper->upsert( + 'grp_7', + 'year class', + 'School Alpha', + 'puavoId=1,ou=Groups', + 'Class 1A', + 'c1a-alpha', + ); + + // The name the teacher actually sees in the picker. + $this->assertSame(['grp_7'], array_column($this->mapper->searchEntries('Class', 100, 0), 'gid')); + // The stale gid is NOT searchable: it is no longer what the group is + // called, and may have become another group's name. + $this->assertSame([], $this->mapper->searchEntries('grp_7', 100, 0)); + // Nor is the abbreviation: it never appears in the label, so a hit on + // it would look to the teacher like an unrelated group. + $this->assertSame([], $this->mapper->searchEntries('c1a-alpha', 100, 0)); + + $this->assertSame([], $this->mapper->searchEntries('nomatch', 100, 0)); + } + + public function testSearchDoesNotReturnTheWrongCohortAfterAYearRollover(): void { + $school = 'puavoId=1,ou=Groups'; + // The class that moved up a year: renamed to '5. class', but Nextcloud + // froze its gid at the name it had when first mapped. + $this->mapper->upsert('4. class', 'year class', 'School Alpha', $school, '5. class', 'alpha-2021'); + // The incoming cohort now carries the freed-up name. user_ldap gave it + // a '_2' gid because '4. class' was already taken. + $this->mapper->upsert('4. class_2', 'year class', 'School Alpha', $school, '4. class', 'alpha-2022'); + + // Each search returns exactly the group that is called that today. + $this->assertSame( + ['4. class_2'], + array_column($this->mapper->searchEntries('4. class', 100, 0), 'gid'), + ); + $this->assertSame( + ['4. class'], + array_column($this->mapper->searchEntries('5. class', 100, 0), 'gid'), + ); + } + + public function testSearchFallsBackToGidWhileDisplayNameIsUnset(): void { + // Rows written before the first sync populated display_name must stay + // findable, otherwise upgrading hides every group until the job runs. + $this->mapper->upsert('not_yet_synced', 'year class', 'School Alpha', 'puavoId=1,ou=Groups'); + + $this->assertSame( + ['not_yet_synced'], + array_column($this->mapper->searchEntries('not_yet', 100, 0), 'gid'), + ); + } + + public function testSearchResultsAreOrderedByVisibleName(): void { + $school = 'puavoId=1,ou=Groups'; + $this->mapper->upsert('zzz_gid', 'year class', 'School Alpha', $school, 'A class', 'a-alpha'); + $this->mapper->upsert('aaa_gid', 'year class', 'School Alpha', $school, 'B class', 'b-alpha'); + + // Sorted by display_name, not by the gids they happen to be stored under. + $this->assertSame( + ['zzz_gid', 'aaa_gid'], + array_column($this->mapper->searchEntries('class', 100, 0), 'gid'), + ); + } + public function testSearchEntriesForSchoolsFiltersByDn(): void { $schoolA = 'puavoId=1,ou=Groups'; $schoolB = 'puavoId=2,ou=Groups'; diff --git a/tests/Unit/Service/LdapSyncTest.php b/tests/Unit/Service/LdapSyncTest.php index 918c9c9..b3357a9 100644 --- a/tests/Unit/Service/LdapSyncTest.php +++ b/tests/Unit/Service/LdapSyncTest.php @@ -290,6 +290,7 @@ public function getConnection(): object { return new class { public string $ldapGroupFilter = '(objectClass=groupOfFakes)'; public string $ldapUserFilter = '(objectClass=puavoEduPerson)'; + public string $ldapGroupDisplayName = 'displayName'; }; }