Complete account schema foundation

This commit is contained in:
diyaa
2026-06-24 07:36:57 +02:00
parent 538d259b63
commit babea87140
16 changed files with 789 additions and 57 deletions
@@ -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();
}
}
+19
View File
@@ -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);
});
});
+7 -2
View File
@@ -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 {}