multiple groups per user

This commit is contained in:
azykov@mail.ru
2026-09-30 09:06:10 +03:00
parent 45158cde38
commit c197e5978a
21 changed files with 749 additions and 277 deletions
+88 -7
View File
@@ -24,7 +24,7 @@ async function setup() {
const { app, config } = await testApp();
const root = admin(app, SUPERADMIN);
const me = (await root('GET', '/me')).body as MeResponse;
return { app, config, root, defaultGroup: me.group };
return { app, config, root, defaultGroup: me.currentGroup };
}
describe('admin API authentication', () => {
@@ -32,7 +32,13 @@ describe('admin API authentication', () => {
const { root } = await setup();
const { status, body } = await root('GET', '/me');
expect(status).toBe(200);
expect(body).toMatchObject({ email: SUPERADMIN, role: 'superadmin', group: { name: 'default' } });
expect(body).toMatchObject({
email: SUPERADMIN,
superadmin: true,
role: 'superadmin',
groups: [{ name: 'default', role: 'admin' }],
currentGroup: { name: 'default' },
});
});
it('returns 401 without header or for unknown users', async () => {
@@ -77,18 +83,33 @@ describe('groups and current group', () => {
expect(((await stale('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id);
});
it('ignores the group cookie for non-superadmins', async () => {
it('lets members switch only between their own groups', async () => {
const { app, root, defaultGroup } = await setup();
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
await root('POST', '/users', { email: 'adm@example.com', role: 'admin' });
const adm = admin(app, 'adm@example.com', `cardy_group=${other.id}`);
// Not a member of "Other" yet: the cookie is ignored and switching is refused.
expect(((await adm('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id);
expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(403);
expect((await adm('GET', '/groups')).status).toBe(403);
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
expect((await inOther('POST', '/users', { email: 'adm@example.com' })).status).toBe(201);
expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(200);
const me = (await adm('GET', '/me')).body as MeResponse;
expect(me.currentGroup.id).toBe(other.id);
expect(me.role).toBe('user');
expect(me.groups.map((g) => [g.name, g.role])).toEqual([
['default', 'admin'],
['Other', 'user'],
]);
// Admin in "default" only: no admin rights in "Other".
expect((await adm('GET', '/users')).status).toBe(403);
});
it('refuses to delete a group with a superadmin, deletes others with users and cards', async () => {
it('refuses to delete a group that is the only group of some user', async () => {
const { app, config, root, defaultGroup } = await setup();
// "default" is the superadmin's only group.
expect((await root('DELETE', `/groups/${defaultGroup.id}`)).status).toBe(409);
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
@@ -97,9 +118,16 @@ describe('groups and current group', () => {
await inOther('POST', '/cards', { name: 'Shop', color: '#112233', code: '123', codeType: 'CODE128' });
expect(existsSync(path.join(config.dataDir, 'groups', other.id, 'cards.yaml'))).toBe(true);
const refused = await root('DELETE', `/groups/${other.id}`);
expect(refused.status).toBe(409);
expect((refused.body as { error: string }).error).toContain('u@example.com');
// Once the user has another group, the group can go: its cards are removed, the user stays.
await root('POST', '/users', { email: 'u@example.com' });
expect((await root('DELETE', `/groups/${other.id}`)).status).toBe(204);
expect(existsSync(path.join(config.dataDir, 'groups', other.id))).toBe(false);
expect((await admin(app, 'u@example.com')('GET', '/me')).status).toBe(401);
const me = (await admin(app, 'u@example.com')('GET', '/me')).body as MeResponse;
expect(me.groups.map((g) => g.id)).toEqual([defaultGroup.id]);
});
});
@@ -113,7 +141,8 @@ describe('users and keys', () => {
expect((await user('GET', '/users')).status).toBe(403);
expect((await user('GET', '/cards')).status).toBe(403);
expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(403);
expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(400);
expect((await adm('PATCH', '/users/user@example.com', { superadmin: true })).status).toBe(403);
expect((await adm('PATCH', `/users/${SUPERADMIN}`, { role: 'user' })).status).toBe(403);
expect((await adm('DELETE', `/users/${SUPERADMIN}`)).status).toBe(403);
expect((await adm('PATCH', '/users/adm@example.com', { role: 'user' })).status).toBe(400);
@@ -121,7 +150,59 @@ describe('users and keys', () => {
expect((await adm('PATCH', '/users/user@example.com', { role: 'admin' })).status).toBe(200);
expect((await adm('POST', '/users', { email: 'user@example.com' })).status).toBe(409);
expect((await adm('POST', '/users', { email: 'not-an-email' })).status).toBe(400);
expect((await root('PATCH', `/users/${SUPERADMIN}`, { role: 'admin' })).status).toBe(400);
expect((await root('PATCH', `/users/${SUPERADMIN}`, { superadmin: false })).status).toBe(400);
const promoted = await root('PATCH', '/users/user@example.com', { superadmin: true });
expect((promoted.body as UserView).superadmin).toBe(true);
// Now a superadmin: plain admins can no longer manage them.
expect((await adm('PATCH', '/users/user@example.com', { role: 'user' })).status).toBe(403);
});
it('keeps roles per group and removes users from one group or entirely', async () => {
const { app, root } = await setup();
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
await root('POST', '/users', { email: 'multi@example.com', role: 'admin' });
const added = await inOther('POST', '/users', { email: 'multi@example.com', role: 'user' });
expect(added.status).toBe(201);
expect((added.body as UserView).role).toBe('user');
expect((added.body as UserView).groups.map((g) => [g.name, g.role])).toEqual([
['default', 'admin'],
['Other', 'user'],
]);
// Leaving one group keeps the user.
expect((await inOther('DELETE', '/users/multi@example.com')).body).toEqual({ deleted: false });
const me = (await admin(app, 'multi@example.com')('GET', '/me')).body as MeResponse;
expect(me.groups.map((g) => g.name)).toEqual(['default']);
// Removing the user from their only group deletes the user.
expect((await root('DELETE', '/users/multi@example.com')).body).toEqual({ deleted: true });
expect((await admin(app, 'multi@example.com')('GET', '/me')).status).toBe(401);
});
it('reads users.yaml from before multi-group', async () => {
const { mkdir, writeFile } = await import('node:fs/promises');
const { testConfig } = await import('./helpers.js');
const { buildApp } = await import('../src/app.js');
const config = await testConfig();
await mkdir(config.dataDir, { recursive: true });
await writeFile(
path.join(config.dataDir, 'users.yaml'),
[
'groups:',
' - { id: g1, name: Home }',
'users:',
` - { email: ${SUPERADMIN}, groupId: g1, role: superadmin, keys: [] }`,
' - { email: adm@example.com, groupId: g1, role: admin, keys: [] }',
' - { email: kid@example.com, groupId: g1, role: user, keys: [] }',
].join('\n'),
);
const app = await buildApp(config);
const view = (email: string) => admin(app, email)('GET', '/me').then((r) => r.body as MeResponse);
expect(await view(SUPERADMIN)).toMatchObject({ superadmin: true, role: 'superadmin', groups: [{ id: 'g1', role: 'admin' }] });
expect(await view('adm@example.com')).toMatchObject({ superadmin: false, role: 'admin', groups: [{ id: 'g1', role: 'admin' }] });
expect(await view('kid@example.com')).toMatchObject({ superadmin: false, role: 'user', groups: [{ id: 'g1', role: 'user' }] });
});
it('admin generates keys; users see their own keys with deep links', async () => {
+37 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { describe, expect, it } from 'vitest';
import type { AccessKeyView, Card, Group } from '@cardy/shared';
import type { AccessKeyView, ApiAuthResponse, Card, Group } from '@cardy/shared';
import { SUPERADMIN, multipart, testApp } from './helpers.js';
const adminHeaders = { 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' };
@@ -179,6 +179,42 @@ describe('client API', () => {
expect((patched.json() as Card).tags).toEqual(['a', 'b', 'c']);
});
it('serves each of the owner\'s groups and keeps /cards on the first one', async () => {
const { app, key } = await setup();
const api = client(app, key);
const other = (
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Other' } })
).json() as Group;
const stranger = (
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Stranger' } })
).json() as Group;
await app.inject({
method: 'POST',
url: '/admin/users',
headers: { ...adminHeaders, cookie: `cardy_group=${other.id}` },
payload: { email: SUPERADMIN },
});
const auth = (await api('GET', '/auth')).json() as ApiAuthResponse;
expect(auth.groups.map((g) => g.name)).toEqual(['default', 'Other']);
expect(auth.group.name).toBe('default');
const inOther = (await api('POST', `/groups/${other.id}/cards`, cardParts({ name: 'In other' }))).json() as Card;
const inDefault = (await api('POST', `/groups/${auth.groups[0]!.id}/cards`, cardParts({ name: 'In default' }))).json() as Card;
expect(((await api('GET', `/groups/${other.id}/cards`)).json() as Card[]).map((c) => c.name)).toEqual(['In other']);
// The group-less routes (apps from before multi-group) use the first group.
expect(((await api('GET', '/cards')).json() as Card[]).map((c) => c.name)).toEqual(['In default']);
// Cards are addressed within their group only.
expect((await api('DELETE', `/groups/${other.id}/cards/${inDefault.id}`)).statusCode).toBe(404);
expect((await api('PUT', `/groups/${other.id}/cards/${inOther.id}`, [{ name: 'name', value: 'Renamed' }])).json()).toMatchObject({
name: 'Renamed',
});
// Groups the owner is not a member of do not exist for the key.
expect((await api('GET', `/groups/${stranger.id}/cards`)).statusCode).toBe(404);
expect((await api('POST', `/groups/${stranger.id}/cards`, cardParts())).statusCode).toBe(404);
});
it('returns JSON 404 for unknown API routes', async () => {
const { app, key } = await setup();
const res = await client(app, key)('GET', '/nope');