Complete account schema foundation
This commit is contained in:
@@ -23,6 +23,7 @@ describe('ProtectedDeviceAuthMiddleware', () => {
|
||||
'/api/v1/uploads/upload-id/file',
|
||||
'/api/v1/uploads/upload-id/finalize',
|
||||
'/api/v1/devices/heartbeat',
|
||||
'/api/v1/me',
|
||||
])(
|
||||
'returns 401 before validation-relevant request data can matter when Authorization is missing on %s',
|
||||
async (path) => {
|
||||
@@ -65,6 +66,7 @@ describe('ProtectedDeviceAuthMiddleware', () => {
|
||||
'/api/v1/sync/changes',
|
||||
'/api/v1/uploads/prepare',
|
||||
'/api/v1/devices/heartbeat',
|
||||
'/api/v1/me',
|
||||
])(
|
||||
'returns 401 before validation-relevant request data can matter when Authorization is invalid on %s',
|
||||
async (path) => {
|
||||
|
||||
@@ -15,7 +15,10 @@ const PROTECTED_ROUTE_PREFIXES = [
|
||||
'/api/v1/uploads',
|
||||
];
|
||||
|
||||
const PROTECTED_ROUTE_EXACT_PATHS = new Set(['/api/v1/devices/heartbeat']);
|
||||
const PROTECTED_ROUTE_EXACT_PATHS = new Set([
|
||||
'/api/v1/devices/heartbeat',
|
||||
'/api/v1/me',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class ProtectedDeviceAuthMiddleware implements NestMiddleware {
|
||||
|
||||
@@ -39,7 +39,9 @@ describe('DevicesService', () => {
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
|
||||
expect(ownerContext.resolve).toHaveBeenCalledTimes(1);
|
||||
expect(ownerContext.resolve).toHaveBeenCalledWith({
|
||||
allowLegacyDeviceFallback: false,
|
||||
});
|
||||
expect(prismaService.device.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: ownerId,
|
||||
@@ -52,6 +54,50 @@ describe('DevicesService', () => {
|
||||
expect(response.deviceAccessToken).toBe('device-access-token');
|
||||
});
|
||||
|
||||
it('does not allow raw deviceId fallback to choose the registration owner', async () => {
|
||||
const ownerId = randomUUID();
|
||||
const prismaService = {
|
||||
device: {
|
||||
create: jest.fn().mockImplementation(async ({ data }) => ({
|
||||
id: randomUUID(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...data,
|
||||
})),
|
||||
},
|
||||
} as any;
|
||||
const ownerContext = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
userId: ownerId,
|
||||
}),
|
||||
} as any;
|
||||
const deviceAuthService = {
|
||||
generateDeviceAccessToken: jest.fn().mockReturnValue('device-access-token'),
|
||||
hashDeviceAccessToken: jest.fn().mockReturnValue('device-token-hash'),
|
||||
getAuthenticatedDeviceOrThrow: jest.fn(),
|
||||
} as any;
|
||||
const service = new DevicesService(
|
||||
prismaService,
|
||||
ownerContext as OwnerContext,
|
||||
deviceAuthService as DeviceAuthService,
|
||||
);
|
||||
|
||||
await service.register({
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Velody iPhone',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
|
||||
expect(ownerContext.resolve).toHaveBeenCalledWith({
|
||||
allowLegacyDeviceFallback: false,
|
||||
});
|
||||
expect(prismaService.device.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: ownerId,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects heartbeat updates for a foreign-owner device', async () => {
|
||||
const deviceId = randomUUID();
|
||||
const prismaService = {
|
||||
|
||||
@@ -28,7 +28,9 @@ export class DevicesService {
|
||||
.digest('hex');
|
||||
const tokenHash =
|
||||
this.deviceAuthService.hashDeviceAccessToken(deviceAccessToken);
|
||||
const owner = await this.ownerContext.resolve();
|
||||
const owner = await this.ownerContext.resolve({
|
||||
allowLegacyDeviceFallback: false,
|
||||
});
|
||||
|
||||
const device = await this.prismaService.device.create({
|
||||
data: {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('account foundations migration', () => {
|
||||
it('backfills default owner account metadata and library namespaces', async () => {
|
||||
const migrationSql = await readFile(
|
||||
join(
|
||||
process.cwd(),
|
||||
'prisma/migrations/20260623110000_milestone112_account_foundations/migration.sql',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(migrationSql).toContain(
|
||||
`"account_kind" = 'LEGACY_DEFAULT'::"UserAccountKind"`,
|
||||
);
|
||||
expect(migrationSql).toContain(
|
||||
`"account_status" = 'ACTIVE'::"UserAccountStatus"`,
|
||||
);
|
||||
expect(migrationSql).toContain(
|
||||
`"library_namespace" = COALESCE("library_namespace", gen_random_uuid())`,
|
||||
);
|
||||
expect(migrationSql).toContain(`WHERE "slug" = 'default-owner'`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
||||
import { CurrentAccountResponseDto } from './account.dto';
|
||||
import { AccountService } from './account.service';
|
||||
|
||||
@ApiTags('account')
|
||||
@Controller({
|
||||
version: '1',
|
||||
})
|
||||
export class AccountController {
|
||||
constructor(private readonly accountService: AccountService) {}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ type: CurrentAccountResponseDto })
|
||||
async getMe(): Promise<CurrentAccountResponseDto> {
|
||||
return this.accountService.getCurrentAccount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { UserAccountKind, UserAccountStatus } from '@prisma/client';
|
||||
|
||||
export class CurrentAccountResponseDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
accountId!: string;
|
||||
|
||||
@ApiProperty({ enum: UserAccountKind, example: UserAccountKind.LEGACY_DEFAULT })
|
||||
accountKind!: UserAccountKind;
|
||||
|
||||
@ApiProperty({ enum: UserAccountStatus, example: UserAccountStatus.ACTIVE })
|
||||
accountStatus!: UserAccountStatus;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
libraryNamespace!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
currentDeviceId!: string;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { AccountService } from './account.service';
|
||||
|
||||
describe('AccountService', () => {
|
||||
it('returns the authenticated device owner account metadata', async () => {
|
||||
const accountId = randomUUID();
|
||||
const currentDeviceId = randomUUID();
|
||||
const libraryNamespace = randomUUID();
|
||||
const prismaService = {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: accountId,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
libraryNamespace,
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
const deviceAuthService = {
|
||||
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
|
||||
deviceId: currentDeviceId,
|
||||
userId: accountId,
|
||||
}),
|
||||
} as any;
|
||||
const service = new AccountService(
|
||||
prismaService,
|
||||
deviceAuthService as DeviceAuthService,
|
||||
);
|
||||
|
||||
await expect(service.getCurrentAccount()).resolves.toEqual({
|
||||
accountId,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
libraryNamespace,
|
||||
currentDeviceId,
|
||||
});
|
||||
expect(prismaService.user.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: accountId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
accountKind: true,
|
||||
accountStatus: true,
|
||||
libraryNamespace: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('requires device bearer authentication', async () => {
|
||||
const service = new AccountService(
|
||||
{
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
getAuthenticatedDeviceOrThrow: jest.fn().mockImplementation(() => {
|
||||
throw new UnauthorizedException('Authorization header is required');
|
||||
}),
|
||||
} as any,
|
||||
);
|
||||
|
||||
await expect(service.getCurrentAccount()).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 when the authenticated device owner no longer exists', async () => {
|
||||
const service = new AccountService(
|
||||
{
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
|
||||
deviceId: randomUUID(),
|
||||
userId: randomUUID(),
|
||||
}),
|
||||
} as any,
|
||||
);
|
||||
|
||||
await expect(service.getCurrentAccount()).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { CurrentAccountResponseDto } from './account.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly deviceAuthService: DeviceAuthService,
|
||||
) {}
|
||||
|
||||
async getCurrentAccount(): Promise<CurrentAccountResponseDto> {
|
||||
const authenticatedDevice =
|
||||
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||
|
||||
const account = await this.prismaService.user.findUnique({
|
||||
where: {
|
||||
id: authenticatedDevice.userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
accountKind: true,
|
||||
accountStatus: true,
|
||||
libraryNamespace: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new NotFoundException('Account not found');
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: account.id,
|
||||
accountKind: account.accountKind,
|
||||
accountStatus: account.accountStatus,
|
||||
libraryNamespace: account.libraryNamespace,
|
||||
currentDeviceId: authenticatedDevice.deviceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ describe('DefaultUserService', () => {
|
||||
slug: DefaultUserService.defaultOwnerSlug,
|
||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||
isDefault: true,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
libraryNamespace: randomUUID(),
|
||||
libraryCursor: 0n,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -27,11 +30,15 @@ describe('DefaultUserService', () => {
|
||||
update: {
|
||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||
isDefault: true,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
},
|
||||
create: {
|
||||
slug: DefaultUserService.defaultOwnerSlug,
|
||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||
isDefault: true,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -49,6 +56,9 @@ describe('DefaultUserService', () => {
|
||||
slug: DefaultUserService.defaultOwnerSlug,
|
||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||
isDefault: true,
|
||||
accountKind: 'LEGACY_DEFAULT',
|
||||
accountStatus: 'ACTIVE',
|
||||
libraryNamespace: randomUUID(),
|
||||
libraryCursor: 0n,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { User } from '@prisma/client';
|
||||
import { User, UserAccountKind, UserAccountStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -21,11 +21,15 @@ export class DefaultUserService implements OnApplicationBootstrap {
|
||||
update: {
|
||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||
isDefault: true,
|
||||
accountKind: UserAccountKind.LEGACY_DEFAULT,
|
||||
accountStatus: UserAccountStatus.ACTIVE,
|
||||
},
|
||||
create: {
|
||||
slug: DefaultUserService.defaultOwnerSlug,
|
||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||
isDefault: true,
|
||||
accountKind: UserAccountKind.LEGACY_DEFAULT,
|
||||
accountStatus: UserAccountStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,4 +103,40 @@ describe('BootstrapOwnerContextService', () => {
|
||||
});
|
||||
expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the bootstrap default owner when legacy device fallback is disabled', async () => {
|
||||
const requestContext = new RequestContextService();
|
||||
const defaultUser = {
|
||||
id: randomUUID(),
|
||||
slug: 'default-owner',
|
||||
displayName: 'Default Owner',
|
||||
isDefault: true,
|
||||
};
|
||||
const defaultUserService = {
|
||||
getOrCreateDefaultUser: jest.fn().mockResolvedValue(defaultUser),
|
||||
} as any;
|
||||
const prismaService = {
|
||||
device: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
} as any;
|
||||
const service = new BootstrapOwnerContextService(
|
||||
defaultUserService,
|
||||
requestContext,
|
||||
prismaService,
|
||||
);
|
||||
|
||||
await requestContext.run(async () => {
|
||||
requestContext.setLegacyDeviceId(randomUUID());
|
||||
|
||||
await expect(
|
||||
service.resolve({ allowLegacyDeviceFallback: false }),
|
||||
).resolves.toEqual({
|
||||
userId: defaultUser.id,
|
||||
});
|
||||
});
|
||||
|
||||
expect(prismaService.device.findUnique).not.toHaveBeenCalled();
|
||||
expect(defaultUserService.getOrCreateDefaultUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
import { DefaultUserService } from './default-user.service';
|
||||
import {
|
||||
BootstrapOwnerContextService,
|
||||
@@ -8,8 +11,10 @@ import {
|
||||
} from './owner-context.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RequestContextModule],
|
||||
imports: [PrismaModule, RequestContextModule, AuthModule],
|
||||
controllers: [AccountController],
|
||||
providers: [
|
||||
AccountService,
|
||||
DefaultUserService,
|
||||
BootstrapOwnerContextService,
|
||||
{
|
||||
@@ -17,6 +22,6 @@ import {
|
||||
useExisting: BootstrapOwnerContextService,
|
||||
},
|
||||
],
|
||||
exports: [DefaultUserService, OwnerContext],
|
||||
exports: [DefaultUserService, OwnerContext, AccountService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
Reference in New Issue
Block a user