Files
sso-manager-node/nodejs/tests/nested_group.test.js
wmantly 4a592f9795 Release 1.11.0: end-user catalog, access requests, nested groups
Closes the end-user half of the directory and adds nested LDAP groups.

The directory could describe the lab but could not tell anyone what they had
or how to reach it, and several of the paths meant to do so were silently
returning nothing:

  - GET /api/discovery/me resolved groups from req.user.groups, which does not
    exist (req.user carries memberOf), so it returned only isPublic resources
    for every human caller -- "My Services" was blank for everyone. The same
    read made isDirectoryAdmin() false for real admins.
  - The portal's "Discover More Services" called the admin-gated endpoint and
    swallowed the 403, so it never rendered for non-admins at all.
  - Services reported no address, because /me had reimplemented getMyAccess
    without its parent-walking resolution.

Adds the catalog at /, self-service access requests, and admin access
visibility (per-resource counts, and the reverse "what can this user reach").

Nested groups come in two halves. groupOfNames.member already accepts a group
DN, so nesting needs no schema -- what it needs is resolution, which no
released OpenLDAP performs. The all-in-one image therefore builds slapd from a
pinned master commit for the nestgroup overlay, and the app computes the
closure itself when pointed at a server without it. Both paths are covered.

member-values is deliberately left out of nestgroup-flags: it expands `member`
when reading a group, which destroys the distinction between "listed here" and
"reachable through a nested group" and is not recoverable afterwards.

Full suite green in both resolution modes: 215 passed, 2 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:22:08 -04:00

137 lines
5.6 KiB
JavaScript

'use strict';
// Nested groups: the API for putting a group inside a group, the cycle guard,
// and the thing that makes it worth doing -- membership resolving transitively
// through the chain.
//
// Fixture note that is easy to get wrong: groupOfNames requires at least one
// member, so whoever creates a group is seeded into it. `test` creates all
// three groups here and would therefore be a *direct* member of each, which
// would make "resolved via nesting" indistinguishable from "was already in it".
// Setup below strips that back so test's only direct membership is the
// innermost group -- and the strip has to happen after nesting, or removing the
// sole member would violate the objectClass.
const { login, request, app } = require('./setup');
let token;
const stamp = Date.now().toString(36);
const A = `nesttest-a-${stamp}`; // outermost
const B = `nesttest-b-${stamp}`; // middle
const C = `nesttest-c-${stamp}`; // innermost, holds the user
// A second group nested into A purely so that un-nesting B later does not
// empty A -- groupOfNames requires at least one member, and the API correctly
// refuses (409) rather than leaving an invalid entry behind.
const D = `nesttest-d-${stamp}`;
async function nest(parent, child) {
return request(app).put(`/api/group/${parent}/nested/${child}`).set('auth-token', token).send({});
}
beforeAll(async () => {
token = await login();
for (const cn of [A, B, C, D]) {
const res = await request(app)
.post('/api/group')
.set('auth-token', token)
.send({ name: cn, description: `nesting test ${cn}` });
expect([200, 201]).toContain(res.status);
}
expect((await nest(A, B)).status).toBe(200);
expect((await nest(B, C)).status).toBe(200);
expect((await nest(A, D)).status).toBe(200);
// Now that A holds B and B holds C, neither would be left memberless.
for (const cn of [A, B]) {
const res = await request(app).delete(`/api/group/${cn}/test`).set('auth-token', token);
expect(res.status).toBe(200);
}
});
describe('Nested groups — API guards', () => {
test('nesting the same pair twice is a 409, not a duplicate', async () => {
const res = await nest(A, B);
expect(res.status).toBe(409);
});
test('a group cannot contain itself', async () => {
const res = await nest(A, A);
expect(res.status).toBe(400);
});
// The guard that matters: without it the resolver would silently return a
// depth-capped answer instead of an error anyone would notice.
test('a direct cycle is refused (A contains B, so B may not contain A)', async () => {
const res = await nest(B, A);
expect(res.status).toBe(409);
expect(res.body.message).toMatch(/loop/i);
});
test('an indirect cycle is refused too (A>B>C, so C may not contain A)', async () => {
const res = await nest(C, A);
expect(res.status).toBe(409);
});
});
describe('Nested groups — resolution', () => {
test('membership resolves through the whole chain', async () => {
const res = await request(app).get('/api/group?member=test').set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results).toContain(C); // direct
expect(res.body.results).toContain(B); // via C
expect(res.body.results).toContain(A); // via B -> C
});
test('GET /:group/effective separates direct members from nested ones', async () => {
const res = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
expect(res.status).toBe(200);
const { direct, nestedGroups, effective } = res.body.results;
expect(nestedGroups.map(g => g.cn)).toContain(B);
// `direct` is users only -- a nested group must never be reported as one.
expect(direct.every(dn => !/,ou=groups,/i.test(dn))).toBe(true);
// test is not listed on A at all, yet is effectively a member two levels down.
expect(direct.some(dn => /cn=test,/i.test(dn))).toBe(false);
expect(effective.some(dn => /cn=test,/i.test(dn))).toBe(true);
});
});
describe('Nested groups — un-nesting', () => {
test('DELETE removes the nesting and the membership it carried', async () => {
// Before: A holds B (which holds C, which holds test) and D.
const before = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
expect(before.body.results.nestedGroups.map(g => g.cn)).toContain(B);
expect(before.body.results.effective.some(dn => /cn=test,/i.test(dn))).toBe(true);
const res = await request(app)
.delete(`/api/group/${A}/nested/${B}`)
.set('auth-token', token);
expect(res.status).toBe(200);
const after = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
expect(after.body.results.nestedGroups.map(g => g.cn)).not.toContain(B);
expect(after.body.results.nestedGroups.map(g => g.cn)).toContain(D); // untouched
// test still resolves to B and C directly/through C; only the A path via
// B is gone. It is deliberately NOT asserted that test loses A entirely:
// D is also nested in A and test created D, so that path remains -- which
// is itself a fair illustration of why "who can reach this" has to be
// computed rather than eyeballed.
const groups = await request(app).get('/api/group?member=test').set('auth-token', token);
expect(groups.body.results).toContain(C);
expect(groups.body.results).toContain(B);
});
test('un-nesting the last member is refused rather than emptying the group', async () => {
// B now holds only C. Removing it would leave B with no members at all,
// which groupOfNames forbids.
const res = await request(app)
.delete(`/api/group/${B}/nested/${C}`)
.set('auth-token', token);
expect(res.status).toBe(409);
expect(res.body.message).toMatch(/at least one member/i);
});
});