Files
Immich/server/test/medium/specs/services/passkey.service.spec.ts
T
renovate[bot]andCodex 7fb7699c06 chore(deps): update dependency eslint-plugin-unicorn to v78 (#32588)
Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 72e2bc8b0bc4a7e0a84ac16c0bde7f5e538729fb)
2026-10-28 20:41:28 +03:00

310 lines
11 KiB
TypeScript

import { Kysely } from 'kysely';
import { AuthChallengeType, SystemMetadataKey } from 'src/enum.js';
import { AccessRepository } from 'src/repositories/access.repository.js';
import { AuthChallengeRepository } from 'src/repositories/auth-challenge.repository.js';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
import { ConfigRepository } from 'src/repositories/config.repository.js';
import { CryptoRepository } from 'src/repositories/crypto.repository.js';
import { DatabaseRepository } from 'src/repositories/database.repository.js';
import { EventRepository } from 'src/repositories/event.repository.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js';
import { PasskeyRepository } from 'src/repositories/passkey.repository.js';
import { SessionRepository } from 'src/repositories/session.repository.js';
import { StorageRepository } from 'src/repositories/storage.repository.js';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository.js';
import { TelemetryRepository } from 'src/repositories/telemetry.repository.js';
import { UserRepository } from 'src/repositories/user.repository.js';
import { WebAuthnRepository } from 'src/repositories/webauthn.repository.js';
import { DB } from 'src/schema/index.js';
import { PasskeyService } from 'src/services/passkey.service.js';
import { mediumFactory, newMediumService } from 'test/medium.factory.js';
import { factory } from 'test/small.factory.js';
import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>;
const origin = 'https://immich.example';
const headers = { origin };
const registrationResponse = {
id: 'credential-id',
rawId: 'credential-id',
type: 'public-key',
clientExtensionResults: {},
response: {
clientDataJSON: 'client-data',
attestationObject: 'attestation-object',
},
};
const authenticationResponse = {
id: 'credential-id',
rawId: 'credential-id',
type: 'public-key',
clientExtensionResults: {},
response: {
clientDataJSON: 'client-data',
authenticatorData: 'authenticator-data',
signature: 'signature',
},
};
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => {
return newMediumService(PasskeyService, {
database: db || defaultDatabase,
real: [
AccessRepository,
AuthChallengeRepository,
ClusterGroupRepository,
ConfigRepository,
CryptoRepository,
DatabaseRepository,
PasskeyRepository,
SessionRepository,
SystemMetadataRepository,
UserRepository,
WebAuthnRepository,
],
mock: [LoggingRepository, StorageRepository, EventRepository, TelemetryRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
const { ctx } = setup();
await ctx.get(SystemMetadataRepository).set(SystemMetadataKey.SystemConfig, {
passkey: { enabled: true, domain: 'https://immich.example', additionalDomains: [] },
});
});
describe(PasskeyService.name, () => {
describe('startRegistration', () => {
it('should generate registration options', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await expect(sut.startRegistration(factory.auth({ user, session: {} }), headers)).resolves.toEqual(
expect.objectContaining({
challenge: expect.any(String),
rp: { id: 'immich.example', name: 'Immich' },
user: expect.objectContaining({ name: user.email, displayName: user.name }),
excludeCredentials: [],
authenticatorSelection: expect.objectContaining({ residentKey: 'required', userVerification: 'required' }),
}),
);
});
it('should store a single-use challenge for the user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { challenge } = await sut.startRegistration(factory.auth({ user, session: {} }), headers);
const repository = ctx.get(AuthChallengeRepository);
await expect(
repository.consume({ type: AuthChallengeType.PasskeyAuthentication, challenge }),
).resolves.toBeUndefined();
await expect(repository.consume({ type: AuthChallengeType.PasskeyRegistration, challenge })).resolves.toEqual({
id: expect.any(String),
userId: user.id,
});
await expect(
repository.consume({ type: AuthChallengeType.PasskeyRegistration, challenge }),
).resolves.toBeUndefined();
});
it('should exclude the existing passkeys of the user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id });
await expect(sut.startRegistration(factory.auth({ user, session: {} }), headers)).resolves.toEqual(
expect.objectContaining({
excludeCredentials: [{ id: passkey.credentialId, type: 'public-key', transports: passkey.transports }],
}),
);
});
});
describe('finishRegistration', () => {
it('should reject an invalid response', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await expect(
sut.finishRegistration(
factory.auth({ user, session: {} }),
{ response: registrationResponse },
headers,
mediumFactory.loginDetails(),
),
).rejects.toThrow('Passkey registration failed');
await expect(ctx.get(PasskeyRepository).search({ userId: user.id })).resolves.toEqual([]);
});
});
describe('startAuthentication', () => {
it('should store a single-use challenge without a user', async () => {
const { sut, ctx } = setup();
const { challenge } = await sut.startAuthentication(headers);
const repository = ctx.get(AuthChallengeRepository);
await expect(repository.consume({ type: AuthChallengeType.PasskeyAuthentication, challenge })).resolves.toEqual({
id: expect.any(String),
userId: null,
});
await expect(
repository.consume({ type: AuthChallengeType.PasskeyAuthentication, challenge }),
).resolves.toBeUndefined();
});
it('should generate authentication options', async () => {
const { sut } = setup();
await expect(sut.startAuthentication(headers)).resolves.toEqual(
expect.objectContaining({
challenge: expect.any(String),
rpId: 'immich.example',
userVerification: 'required',
}),
);
});
});
describe('finishAuthentication', () => {
it('should reject an unknown credential', async () => {
const { sut } = setup();
await expect(
sut.finishAuthentication({ response: authenticationResponse }, headers, mediumFactory.loginDetails()),
).rejects.toThrow('Invalid passkey');
});
it('should reject an invalid response for a known credential', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id, credentialId: authenticationResponse.id });
await expect(
sut.finishAuthentication({ response: authenticationResponse }, headers, mediumFactory.loginDetails()),
).rejects.toThrow('Invalid passkey');
await expect(ctx.get(PasskeyRepository).get(passkey.id)).resolves.toEqual(
expect.objectContaining({ usedAt: null }),
);
await expect(ctx.get(SessionRepository).getByUserId(user.id)).resolves.toEqual([]);
});
});
describe('search', () => {
it('should return an empty list', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await expect(sut.search(factory.auth({ user }), {})).resolves.toEqual([]);
});
it('should only return the passkeys of the current user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id });
await ctx.newPasskey({ userId: otherUser.id });
await expect(sut.search(factory.auth({ user }), {})).resolves.toEqual([
expect.objectContaining({ id: passkey.id, name: passkey.name, usedAt: null }),
]);
});
it('should filter by id', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id });
await ctx.newPasskey({ userId: user.id });
await expect(sut.search(factory.auth({ user }), { id: passkey.id })).resolves.toEqual([
expect.objectContaining({ id: passkey.id }),
]);
});
});
describe('get', () => {
it('should reject the passkey of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: otherUser.id });
await expect(sut.get(factory.auth({ user }), passkey.id)).rejects.toThrow('Not found or no passkey.read access');
});
it('should get a passkey', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id });
await expect(sut.get(factory.auth({ user }), passkey.id)).resolves.toEqual({
id: passkey.id,
name: passkey.name,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
usedAt: null,
});
});
});
describe('update', () => {
it('should reject the passkey of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: otherUser.id });
await expect(sut.update(factory.auth({ user }), passkey.id, { name: 'Renamed' })).rejects.toThrow(
'Not found or no passkey.update access',
);
});
it('should rename a passkey', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id });
await expect(sut.update(factory.auth({ user }), passkey.id, { name: 'Renamed' })).resolves.toEqual(
expect.objectContaining({ id: passkey.id, name: 'Renamed' }),
);
await expect(ctx.get(PasskeyRepository).get(passkey.id)).resolves.toEqual(
expect.objectContaining({ name: 'Renamed' }),
);
});
});
describe('delete', () => {
it('should reject the passkey of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: otherUser.id });
await expect(sut.delete(factory.auth({ user }), passkey.id)).rejects.toThrow(
'Not found or no passkey.delete access',
);
await expect(ctx.get(PasskeyRepository).get(passkey.id)).resolves.toEqual(
expect.objectContaining({ id: passkey.id }),
);
});
it('should delete a passkey', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id });
await sut.delete(factory.auth({ user }), passkey.id);
await expect(ctx.get(PasskeyRepository).get(passkey.id)).resolves.toBeUndefined();
});
});
});