@furystack/rest-service
Version:
Repository implementation for FuryStack
330 lines • 17.6 kB
JavaScript
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { InMemoryStore, StoreManager, User, addStore } from '@furystack/core';
import { Injector } from '@furystack/inject';
import { PasswordAuthenticator, PasswordCredential, UnauthenticatedError } from '@furystack/security';
import { usingAsync } from '@furystack/utils';
import { describe, expect, it, vi } from 'vitest';
import { useHttpAuthentication } from './helpers.js';
import { HttpUserContext } from './http-user-context.js';
import { DefaultSession } from './models/default-session.js';
export const prepareInjector = async (i) => {
addStore(i, new InMemoryStore({ model: User, primaryKey: 'username' }))
.addStore(new InMemoryStore({ model: DefaultSession, primaryKey: 'sessionId' }))
.addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }));
useHttpAuthentication(i);
};
const setupUser = async (i, userName, password) => {
const sm = i.getInstance(StoreManager);
const pw = i.getInstance(PasswordAuthenticator);
const cred = await pw.hasher.createCredential(userName, password);
await sm.getStoreFor(PasswordCredential, 'userName').add(cred);
await sm.getStoreFor(User, 'username').add({ username: userName, roles: [] });
};
describe('HttpUserContext', () => {
const request = { headers: {} };
const response = {};
const testUser = { username: 'testUser', roles: ['grantedRole1', 'grantedRole2'] };
it('Should be constructed with the extension method', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
expect(ctx).toBeInstanceOf(HttpUserContext);
});
});
describe('isAuthenticated', () => {
it('Should return true for authenticated users', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
ctx.getCurrentUser = vi.fn(async () => testUser);
const value = await ctx.isAuthenticated(request);
expect(value).toBe(true);
expect(ctx.getCurrentUser).toBeCalled();
});
});
it('Should return false for unauthenticated users', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
ctx.getCurrentUser = vi.fn(async () => {
throw Error(':(');
});
await expect(ctx.isAuthenticated(request)).resolves.toEqual(false);
expect(ctx.getCurrentUser).toBeCalled();
});
});
});
describe('isAuthorized', () => {
it('Should return true if all roles are authorized', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
ctx.getCurrentUser = vi.fn(async () => testUser);
const value = await ctx.isAuthorized(request, 'grantedRole1', 'grantedRole2');
expect(value).toBe(true);
expect(ctx.getCurrentUser).toBeCalled();
});
});
it('Should return false if not all roles are authorized', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
ctx.getCurrentUser = vi.fn(async () => testUser);
const value = await ctx.isAuthorized(request, 'grantedRole1', 'nonGrantedRole2');
expect(value).toBe(false);
expect(ctx.getCurrentUser).toBeCalled();
});
});
});
describe('authenticateUser', () => {
it('Should fail when the store is empty', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
await expect(ctx.authenticateUser('user', 'password')).rejects.toThrowError(UnauthenticatedError);
});
});
it('Should fail when the password not equals', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
await setupUser(i, 'user', 'pass123');
await expect(i.getInstance(HttpUserContext).authenticateUser('user', 'pass321')).rejects.toThrowError(UnauthenticatedError);
});
});
it('Should fail when the username not equals', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
await setupUser(i, 'otherUser', 'pass123');
await expect(i.getInstance(HttpUserContext).authenticateUser('user', 'pass123')).rejects.toThrowError(UnauthenticatedError);
});
});
it('Should fail when password not provided', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
await setupUser(i, 'user', 'pass123');
await expect(i.getInstance(HttpUserContext).authenticateUser('user', '')).rejects.toThrowError(UnauthenticatedError);
});
});
it('Should fail when the user is not in the user store', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
await setupUser(i, 'user', 'pass123');
await i.getInstance(StoreManager).getStoreFor(User, 'username').remove('user');
await expect(i.getInstance(HttpUserContext).authenticateUser('user', 'pass123')).rejects.toThrowError(UnauthenticatedError);
});
});
it('Should return the user when the username and password matches', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
await setupUser(i, 'user', 'pass123');
const ctx = i.getInstance(HttpUserContext);
const value = await ctx.authenticateUser('user', 'pass123');
expect(value).toEqual({ username: 'user', roles: [] });
});
});
});
describe('getSessionIdFromRequest', () => {
it('Should return null if no headers present', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
const sid = ctx.getSessionIdFromRequest(request);
expect(sid).toBeNull();
});
});
it('Should return null if no session ID cookie present', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const requestWithCookie = { ...request, cookie: 'a=2;b=3;c=4;' };
const ctx = i.getInstance(HttpUserContext);
const sid = ctx.getSessionIdFromRequest(requestWithCookie);
expect(sid).toBeNull();
});
});
it('Should return the Session ID value if session ID cookie present', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
const requestWithAuthCookie = {
...request,
headers: { cookie: `a=2;b=3;${ctx.authentication.cookieName}=666;c=4;` },
};
const sid = ctx.getSessionIdFromRequest(requestWithAuthCookie);
expect(sid).toBe('666');
});
});
});
describe('authenticateRequest', () => {
it('Should try to authenticate with Basic, if enabled', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
ctx.authenticateUser = vi.fn(async () => testUser);
const result = await ctx.authenticateRequest({
headers: { authorization: `Basic dGVzdHVzZXI6cGFzc3dvcmQ=` },
});
expect(ctx.authenticateUser).toBeCalledWith('testuser', 'password');
expect(result).toBe(testUser);
});
});
it('Should NOT try to authenticate with Basic, if disabled', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
ctx.authentication.enableBasicAuth = false;
ctx.authenticateUser = vi.fn(async () => testUser);
await expect(ctx.authenticateRequest({
headers: { authorization: `Basic dGVzdHVzZXI6cGFzc3dvcmQ=` },
})).rejects.toThrowError(UnauthenticatedError);
expect(ctx.authenticateUser).not.toBeCalled();
});
});
it('Should fail with no session in the store', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
await expect(ctx.authenticateRequest({
headers: { cookie: `${ctx.authentication.cookieName}=666;a=3` },
})).rejects.toThrowError(UnauthenticatedError);
});
});
it('Should fail with valid session Id but no user', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
await ctx.authentication
.getSessionStore(i.getInstance(StoreManager))
.add({ sessionId: '666', username: testUser.username });
await expect(ctx.authenticateRequest({
headers: { cookie: `${ctx.authentication.cookieName}=666;a=3` },
})).rejects.toThrowError(UnauthenticatedError);
});
});
it('Should authenticate with cookie, if the session IDs matches', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
await ctx.authentication
.getSessionStore(i.getInstance(StoreManager))
.add({ sessionId: '666', username: testUser.username });
await ctx.authentication.getUserStore(i.getInstance(StoreManager)).add({ ...testUser });
const result = await ctx.authenticateRequest({
headers: { cookie: `${ctx.authentication.cookieName}=666;a=3` },
});
expect(result).toEqual(testUser);
});
});
});
describe('getCurrentUser', () => {
it('Should return the current user from authenticateRequest() once per request', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
ctx.authenticateRequest = vi.fn(async () => testUser);
const result = await ctx.getCurrentUser(request);
const result2 = await ctx.getCurrentUser(request);
expect(ctx.authenticateRequest).toBeCalledTimes(1);
expect(result).toBe(testUser);
expect(result2).toBe(testUser);
});
});
});
describe('cookieLogin', () => {
it('Should return the current user from authenticateRequest() once per request', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
const setHeader = vi.fn();
// @ts-expect-error
ctx.getSessionStore().add = vi.fn(async () => {
return {};
});
const authResult = await ctx.cookieLogin(testUser, { setHeader });
expect(authResult).toBe(testUser);
expect(setHeader).toBeCalled();
expect(ctx.getSessionStore().add).toBeCalled();
});
});
});
describe('cookieLogout', () => {
it('Should invalidate the current session id cookie', async () => {
await usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
const setHeader = vi.fn();
// @ts-expect-error
ctx.getSessionStore().add = vi.fn(async () => {
return {};
});
ctx.authenticateRequest = vi.fn(async () => testUser);
ctx.getSessionStore().remove = vi.fn(async () => undefined);
ctx.getSessionIdFromRequest = () => 'example-session-id';
response.setHeader = vi.fn(() => response);
await ctx.cookieLogin(testUser, { setHeader });
await ctx.cookieLogout(request, response);
expect(response.setHeader).toBeCalledWith('Set-Cookie', 'fss=; Path=/; HttpOnly');
expect(ctx.getSessionStore().remove).toBeCalled();
});
});
});
describe('Changes in the store during the context lifetime', () => {
it('Should update user roles', () => {
return usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
const userStore = i.getInstance(StoreManager).getStoreFor(User, 'username');
await userStore.add(testUser);
const pw = await i.getInstance(PasswordAuthenticator).hasher.createCredential(testUser.username, 'test');
await i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName').add(pw);
await ctx.cookieLogin(testUser, { setHeader: vi.fn() });
const originalUser = await ctx.getCurrentUser(request);
expect(originalUser).toEqual(testUser);
const updatedUser = { ...testUser, roles: ['newFancyRole'] };
await userStore.update(testUser.username, updatedUser);
const updatedUserFromContext = await ctx.getCurrentUser(request);
expect(updatedUserFromContext.roles).toEqual(['newFancyRole']);
await userStore.update(testUser.username, { ...updatedUser, roles: [] });
const reloadedUserFromContext = await ctx.getCurrentUser(request);
expect(reloadedUserFromContext.roles).toEqual([]);
});
});
it('Should remove current user when the user is removed from the store', () => {
return usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
const userStore = i.getInstance(StoreManager).getStoreFor(User, 'username');
await userStore.add(testUser);
const pw = await i.getInstance(PasswordAuthenticator).hasher.createCredential(testUser.username, 'test');
await i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName').add(pw);
await ctx.cookieLogin(testUser, { setHeader: vi.fn() });
const originalUser = await ctx.getCurrentUser(request);
expect(originalUser).toEqual(testUser);
await userStore.remove(testUser.username);
await expect(() => ctx.getCurrentUser(request)).rejects.toThrowError(UnauthenticatedError);
});
});
it('Should remove current user when the session is removed from the store', () => {
return usingAsync(new Injector(), async (i) => {
await prepareInjector(i);
const ctx = i.getInstance(HttpUserContext);
const userStore = i.getInstance(StoreManager).getStoreFor(User, 'username');
await userStore.add(testUser);
let sessionId = '';
const pw = await i.getInstance(PasswordAuthenticator).hasher.createCredential(testUser.username, 'test');
await i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName').add(pw);
await ctx.cookieLogin(testUser, {
setHeader: (_headerName, headerValue) => {
sessionId = headerValue;
return {};
},
});
const originalUser = await ctx.getCurrentUser(request);
expect(originalUser).toEqual(testUser);
const sessionStore = ctx.getSessionStore();
await sessionStore.remove(sessionId);
await expect(() => ctx.getCurrentUser(request)).rejects.toThrowError(UnauthenticatedError);
});
});
});
});
//# sourceMappingURL=http-user-context.spec.js.map