Files
gitmost/apps/server/src/collaboration/extensions/authentication.extension.spec.ts
T
claude_code 683b9d5de2 fix(provenance): address #143 review — page-stamp tests, confine is_agent, doc fixes
Resolves the open items from the latest PR #143 code review:

- test(page): cover the four agentSourceFields stamp sites (create, update,
  movePage, movePageToSpace) with agent + normal-user payload assertions;
  add findById({ includeIsAgent: true }) wiring guards to the JWT and collab
  auth-seam specs so a future drop of the option is caught.
- fix(privacy): drop `isAgent` from UserRepo.baseFields and gate it behind a
  new opt-in `findById({ includeIsAgent })`, requested only by the two auth
  seams that derive provenance — stops the flag leaking via the workspace
  member list and generic user payloads.
- docs: correct the agentSourceFields JSDoc and the two UPDATE-site comments
  to distinguish INSERT (omitted column → DB default 'user') from UPDATE
  (omitted column → existing value kept, Kysely writes only present keys).
- style(page): collapse three stray double blank lines left by an earlier edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:04:23 +03:00

235 lines
8.1 KiB
TypeScript

import {
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { AuthenticationExtension } from './authentication.extension';
import { SpaceRole } from '../../common/helpers/types/permission';
import { JwtType } from '../../core/auth/dto/jwt-payload';
/**
* Unit tests for the collab read-only downgrade matrix in
* `AuthenticationExtension.onAuthenticate`. This is a security boundary: a wrong
* branch here is either a collab-auth bypass (writer on a page they may only
* read) or a denial. We mock every repo and inspect both the thrown exception
* type and the `connectionConfig.readOnly` flag the extension mutates.
*/
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
const USER_ID = 'user-1';
const WORKSPACE_ID = 'ws-1';
const SPACE_ID = 'space-1';
const buildUser = (overrides: Partial<any> = {}) => ({
id: USER_ID,
workspaceId: WORKSPACE_ID,
deactivatedAt: null,
deletedAt: null,
name: 'Alice',
avatarUrl: null,
...overrides,
});
const buildPage = (overrides: Partial<any> = {}) => ({
id: PAGE_ID,
spaceId: SPACE_ID,
workspaceId: WORKSPACE_ID,
deletedAt: null,
...overrides,
});
// Default jwt payload: a plain human collab token (no agent provenance claims).
const buildJwt = (overrides: Partial<any> = {}) => ({
sub: USER_ID,
workspaceId: WORKSPACE_ID,
type: JwtType.COLLAB,
...overrides,
});
describe('AuthenticationExtension.onAuthenticate', () => {
let ext: AuthenticationExtension;
let tokenService: { verifyJwt: jest.Mock };
let userRepo: { findById: jest.Mock };
let pageRepo: { findById: jest.Mock };
let spaceMemberRepo: { getUserSpaceRoles: jest.Mock };
let pagePermissionRepo: { canUserEditPage: jest.Mock };
// Build the hocuspocus onAuthenticate payload. connectionConfig.readOnly
// starts false; the extension flips it to true on a read-only downgrade.
const buildData = (token = 'tok') => ({
documentName: `page.${PAGE_ID}`,
token,
connectionConfig: { readOnly: false },
});
beforeEach(() => {
tokenService = { verifyJwt: jest.fn().mockResolvedValue(buildJwt()) };
userRepo = { findById: jest.fn().mockResolvedValue(buildUser()) };
pageRepo = { findById: jest.fn().mockResolvedValue(buildPage()) };
spaceMemberRepo = {
getUserSpaceRoles: jest
.fn()
.mockResolvedValue([{ userId: USER_ID, role: SpaceRole.WRITER }]),
};
pagePermissionRepo = {
// No page-level restriction by default → defer to space role.
canUserEditPage: jest.fn().mockResolvedValue({
hasAnyRestriction: false,
canAccess: true,
canEdit: true,
}),
};
ext = new AuthenticationExtension(
tokenService as any,
userRepo as any,
pageRepo as any,
spaceMemberRepo as any,
pagePermissionRepo as any,
);
// Silence the extension's logger (it warns/debugs on denial branches).
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
});
it('invalid token → UnauthorizedException (no repo lookups happen)', async () => {
tokenService.verifyJwt.mockRejectedValue(new Error('bad sig'));
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
expect(userRepo.findById).not.toHaveBeenCalled();
});
it('user not found → Unauthorized', async () => {
userRepo.findById.mockResolvedValue(null);
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
});
it('user disabled (deactivatedAt set) → Unauthorized', async () => {
userRepo.findById.mockResolvedValue(
buildUser({ deactivatedAt: new Date() }),
);
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
});
it('page not found → NotFoundException', async () => {
pageRepo.findById.mockResolvedValue(null);
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
NotFoundException,
);
});
it('no space role → Unauthorized', async () => {
spaceMemberRepo.getUserSpaceRoles.mockResolvedValue([]);
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
});
it('page-level restriction canAccess=false → Unauthorized (restricted-page denial)', async () => {
pagePermissionRepo.canUserEditPage.mockResolvedValue({
hasAnyRestriction: true,
canAccess: false,
canEdit: false,
});
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
});
it('restriction canAccess=true & canEdit=false → readOnly (no restricted-page write)', async () => {
pagePermissionRepo.canUserEditPage.mockResolvedValue({
hasAnyRestriction: true,
canAccess: true,
canEdit: false,
});
const data = buildData();
const ctx = await ext.onAuthenticate(data as any);
expect(data.connectionConfig.readOnly).toBe(true);
expect(ctx.actor).toBe('user');
});
it('restriction canAccess=true & canEdit=true → writable (readOnly stays false)', async () => {
pagePermissionRepo.canUserEditPage.mockResolvedValue({
hasAnyRestriction: true,
canAccess: true,
canEdit: true,
});
const data = buildData();
await ext.onAuthenticate(data as any);
expect(data.connectionConfig.readOnly).toBe(false);
});
it('no restriction + space READER → readOnly', async () => {
spaceMemberRepo.getUserSpaceRoles.mockResolvedValue([
{ userId: USER_ID, role: SpaceRole.READER },
]);
const data = buildData();
await ext.onAuthenticate(data as any);
expect(data.connectionConfig.readOnly).toBe(true);
});
it('no restriction + space WRITER → writable', async () => {
const data = buildData();
await ext.onAuthenticate(data as any);
expect(data.connectionConfig.readOnly).toBe(false);
});
it('soft-deleted page (deletedAt set) → readOnly even for a WRITER', async () => {
// A writer must NOT be able to mutate a page in the trash via collab.
pageRepo.findById.mockResolvedValue(buildPage({ deletedAt: new Date() }));
const data = buildData();
await ext.onAuthenticate(data as any);
expect(data.connectionConfig.readOnly).toBe(true);
});
it('agent JWT (actor=agent + aiChatId) propagates into the connection context', async () => {
tokenService.verifyJwt.mockResolvedValue(
buildJwt({ actor: 'agent', aiChatId: 'chat-7' }),
);
const ctx = await ext.onAuthenticate(buildData() as any);
expect(ctx.actor).toBe('agent');
expect(ctx.aiChatId).toBe('chat-7');
expect(ctx.user.id).toBe(USER_ID);
});
it('human JWT (no provenance claims) → actor=user, aiChatId=null', async () => {
const ctx = await ext.onAuthenticate(buildData() as any);
expect(ctx.actor).toBe('user');
expect(ctx.aiChatId).toBeNull();
// Wiring guard (#143): the collab seam MUST opt into the isAgent flag —
// it is not in baseFields, so without this option findById omits it and a
// flagged service account's collab edits would silently persist as 'user'.
expect(userRepo.findById).toHaveBeenCalledWith(
USER_ID,
WORKSPACE_ID,
expect.objectContaining({ includeIsAgent: true }),
);
});
it('is_agent user with NO claim → actor=agent (collab seam consults the signed identity)', async () => {
// Arch A regression guard: a flagged service account editing page CONTENT
// over the collab websocket carries a plain COLLAB token (no actor claim).
// Before the shared resolveProvenance() wiring this seam derived actor from
// the claim alone, so such edits persisted as lastUpdatedSource='user' —
// drifting from the REST seam. The seam must now stamp 'agent' from the
// is_agent flag, matching jwt.strategy.
userRepo.findById.mockResolvedValue(buildUser({ isAgent: true }));
const ctx = await ext.onAuthenticate(buildData() as any);
expect(ctx.actor).toBe('agent');
// No internal ai_chats row for an MCP/service-account collab edit → null.
expect(ctx.aiChatId).toBeNull();
});
});