@accounter/server
Version:
Accounter GraphQL server
155 lines (135 loc) • 5.23 kB
text/typescript
import { describe, expect, it, vi } from 'vitest';
import { AuthContextProvider } from '../../auth/providers/auth-context.provider.js';
import { PendingInvitationsProvider } from '../../auth/providers/pending-invitations.provider.js';
import { viewerResolvers } from '../resolvers/viewer.resolver.js';
type AuthContextValue = Awaited<ReturnType<AuthContextProvider['getAuthContext']>>;
type JwtIdentity = Awaited<ReturnType<AuthContextProvider['getJwtIdentity']>>;
type PendingInvitation = Awaited<
ReturnType<PendingInvitationsProvider['getPendingInvitationsByVerifiedEmail']>
>[number];
async function runResolver(
authContext: AuthContextValue,
jwtIdentity: JwtIdentity,
pendingInvitations: PendingInvitation[] = [],
) {
const authProvider = {
getAuthContext: vi.fn().mockResolvedValue(authContext),
getJwtIdentity: vi.fn().mockResolvedValue(jwtIdentity),
};
const pendingInvitationsProvider = {
getPendingInvitationsByVerifiedEmail: vi.fn().mockResolvedValue(pendingInvitations),
};
const injector = {
get: vi.fn((token: unknown) =>
token === PendingInvitationsProvider ? pendingInvitationsProvider : authProvider,
),
};
const resolver = viewerResolvers.Query!.viewer as unknown as (
parent: unknown,
args: unknown,
context: { injector: { get: (token: unknown) => unknown } },
info: unknown,
) => Promise<Record<string, unknown> | null>;
return {
result: await resolver(undefined, undefined, { injector }, undefined),
authProvider,
pendingInvitationsProvider,
};
}
const linkedContext = {
authType: 'jwt',
user: { email: 'member@example.com', emailVerified: true },
} as unknown as AuthContextValue;
describe('viewer resolver', () => {
it('reports ACTIVE when the identity resolves to an auth context', async () => {
const { result, authProvider } = await runResolver(linkedContext, null);
expect(result).toEqual({
email: 'member@example.com',
emailVerified: true,
status: 'ACTIVE',
pendingInvitations: [],
});
// An active member must not pay for a second JWT verification.
expect(authProvider.getJwtIdentity).not.toHaveBeenCalled();
});
it('reports NO_WORKSPACE for a verified identity with no membership', async () => {
const { result } = await runResolver(null, {
auth0UserId: 'auth0|new-user',
email: 'new@example.com',
emailVerified: true,
});
expect(result).toEqual({
email: 'new@example.com',
emailVerified: true,
status: 'NO_WORKSPACE',
pendingInvitations: [],
});
});
it('reports EMAIL_UNVERIFIED before the email is verified', async () => {
const { result } = await runResolver(null, {
auth0UserId: 'auth0|new-user',
email: 'new@example.com',
emailVerified: false,
});
expect(result).toEqual({
email: 'new@example.com',
emailVerified: false,
status: 'EMAIL_UNVERIFIED',
pendingInvitations: [],
});
});
it('keeps a linked member ACTIVE even when their email is unverified', async () => {
// Membership decides: the API already serves this caller's data, so routing
// them to /welcome would lock out a working account over a claim that is not
// enforced anywhere else.
const { result } = await runResolver(
{
authType: 'jwt',
user: { email: 'member@example.com', emailVerified: false },
} as unknown as AuthContextValue,
null,
);
expect(result).toMatchObject({ status: 'ACTIVE', emailVerified: false });
});
it('lists invitations waiting for a verified email', async () => {
const invitation = {
id: 'inv-1',
businessId: 'biz-1',
businessName: 'Acme Ltd',
roleId: 'employee',
expiresAt: new Date('2030-01-01T00:00:00Z'),
};
const { result, pendingInvitationsProvider } = await runResolver(
null,
{ auth0UserId: 'auth0|new-user', email: 'New@Example.com', emailVerified: true },
[invitation],
);
expect(pendingInvitationsProvider.getPendingInvitationsByVerifiedEmail).toHaveBeenCalledWith(
'New@Example.com',
);
expect(result).toMatchObject({ status: 'NO_WORKSPACE', pendingInvitations: [invitation] });
});
it('never matches invitations against an unverified email', async () => {
// The address is unproven, so matching on it would hand a victim's pending
// invitation to whoever signed up with their address first.
const { result, pendingInvitationsProvider } = await runResolver(
null,
{ auth0UserId: 'auth0|impostor', email: 'victim@example.com', emailVerified: false },
[
{
id: 'inv-1',
businessId: 'biz-1',
businessName: 'Acme Ltd',
roleId: 'employee',
expiresAt: new Date('2030-01-01T00:00:00Z'),
},
],
);
expect(pendingInvitationsProvider.getPendingInvitationsByVerifiedEmail).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: 'EMAIL_UNVERIFIED', pendingInvitations: [] });
});
it('returns null when the request carries no valid credentials', async () => {
const { result } = await runResolver(null, null);
expect(result).toBeNull();
});
});