chore: transition to nullable user.oauthId (#31560)

This commit is contained in:
Jason Rasmussen
2026-09-15 09:33:15 -04:00
committed by GitHub
parent 76e8513316
commit 99fd561144
25 changed files with 244 additions and 45 deletions
@@ -4,10 +4,13 @@ enum ServerCapability {
// Feature Support // Feature Support
cloudIdMetadata(SemVer(major: 2, minor: 4, patch: 0)), cloudIdMetadata(SemVer(major: 2, minor: 4, patch: 0)),
bulkCloudIdMetadata(SemVer(major: 2, minor: 5, patch: 0)), bulkCloudIdMetadata(SemVer(major: 2, minor: 5, patch: 0)),
assetEdits(SemVer(major: 2, minor: 6, patch: 0)),
assetFacesV2(SemVer(major: 2, minor: 6, patch: 0)), // Sync
syncV2(SemVer(major: 3, minor: 0, patch: 0)), syncV2(SemVer(major: 3, minor: 0, patch: 0)),
assetOcr(SemVer(major: 3, minor: 0, patch: 0)), syncAssetEditsV1(SemVer(major: 2, minor: 6, patch: 0)),
syncAssetFacesV2(SemVer(major: 2, minor: 6, patch: 0)),
syncAssetOcrV1(SemVer(major: 3, minor: 0, patch: 0)),
syncAuthUsersV2(SemVer(major: 3, minor: 3, patch: 0)),
// Migrations // Migrations
assetPayloadChange20260128(SemVer(major: 2, minor: 5, patch: 0)), assetPayloadChange20260128(SemVer(major: 2, minor: 5, patch: 0)),
@@ -194,6 +194,8 @@ class SyncStreamService {
switch (type) { switch (type) {
case SyncEntityType.authUserV1: case SyncEntityType.authUserV1:
return _syncStreamRepository.updateAuthUsersV1(data.cast()); return _syncStreamRepository.updateAuthUsersV1(data.cast());
case SyncEntityType.authUserV2:
return _syncStreamRepository.updateAuthUsersV2(data.cast());
case SyncEntityType.userV1: case SyncEntityType.userV1:
return _syncStreamRepository.updateUsersV1(data.cast()); return _syncStreamRepository.updateUsersV1(data.cast());
case SyncEntityType.userDeleteV1: case SyncEntityType.userDeleteV1:
@@ -43,11 +43,11 @@ class SyncApiRepository {
request.body = jsonEncode( request.body = jsonEncode(
SyncStreamDto( SyncStreamDto(
types: [ types: [
SyncRequestType.authUsersV1, serverVersion.supports(.syncAuthUsersV2) ? SyncRequestType.authUsersV2 : SyncRequestType.authUsersV1,
SyncRequestType.usersV1, SyncRequestType.usersV1,
serverVersion.supports(.syncV2) ? SyncRequestType.assetsV2 : SyncRequestType.assetsV1, serverVersion.supports(.syncV2) ? SyncRequestType.assetsV2 : SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1, SyncRequestType.assetExifsV1,
if (serverVersion.supports(.assetEdits)) SyncRequestType.assetEditsV1, if (serverVersion.supports(.syncAssetEditsV1)) SyncRequestType.assetEditsV1,
SyncRequestType.assetMetadataV1, SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1, SyncRequestType.partnersV1,
serverVersion.supports(.syncV2) ? SyncRequestType.partnerAssetsV2 : SyncRequestType.partnerAssetsV1, serverVersion.supports(.syncV2) ? SyncRequestType.partnerAssetsV2 : SyncRequestType.partnerAssetsV1,
@@ -63,8 +63,8 @@ class SyncApiRepository {
SyncRequestType.partnerStacksV1, SyncRequestType.partnerStacksV1,
SyncRequestType.userMetadataV1, SyncRequestType.userMetadataV1,
SyncRequestType.peopleV1, SyncRequestType.peopleV1,
serverVersion.supports(.assetFacesV2) ? SyncRequestType.assetFacesV2 : SyncRequestType.assetFacesV1, serverVersion.supports(.syncAssetFacesV2) ? SyncRequestType.assetFacesV2 : SyncRequestType.assetFacesV1,
if (serverVersion.supports(.assetOcr)) SyncRequestType.assetOcrV1, if (serverVersion.supports(.syncAssetOcrV1)) SyncRequestType.assetOcrV1,
], ],
).toJson(), ).toJson(),
); );
@@ -140,6 +140,7 @@ class SyncApiRepository {
const _kResponseMap = <SyncEntityType, Function(Object)>{ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.authUserV1: SyncAuthUserV1.fromJson, SyncEntityType.authUserV1: SyncAuthUserV1.fromJson,
SyncEntityType.authUserV2: SyncAuthUserV2.fromJson,
SyncEntityType.userV1: SyncUserV1.fromJson, SyncEntityType.userV1: SyncUserV1.fromJson,
SyncEntityType.userDeleteV1: SyncUserDeleteV1.fromJson, SyncEntityType.userDeleteV1: SyncUserDeleteV1.fromJson,
SyncEntityType.partnerV1: SyncPartnerV1.fromJson, SyncEntityType.partnerV1: SyncPartnerV1.fromJson,
@@ -116,6 +116,35 @@ class SyncStreamRepository extends DatabaseAccessor<Drift> with $SyncStreamRepos
} }
} }
Future<void> updateAuthUsersV2(Iterable<SyncAuthUserV2> data) async {
try {
await _db.batch((batch) {
for (final user in data) {
final companion = AuthUserEntityCompanion(
name: Value(user.name),
email: Value(user.email),
hasProfileImage: Value(user.hasProfileImage),
profileChangedAt: Value(user.profileChangedAt),
avatarColor: Value(user.avatarColor.orElse(null)?.toAvatarColor() ?? AvatarColor.primary),
isAdmin: Value(user.isAdmin),
pinCode: Value(user.pinCode),
quotaSizeInBytes: Value(user.quotaSizeInBytes ?? 0),
quotaUsageInBytes: Value(user.quotaUsageInBytes),
);
batch.insert(
_db.authUserEntity,
companion.copyWith(id: Value(user.id)),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (error, stack) {
_logger.severe('Error: SyncAuthUserV2', error, stack);
rethrow;
}
}
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async { Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
try { try {
await _db.batch((batch) { await _db.batch((batch) {
@@ -20,7 +20,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/error_handler.dart'; import 'package:immich_mobile/utils/error_handler.dart';
final _stateProvider = Provider.family.autoDispose<RemoteAsset?, ActionSource>((ref, source) { final _stateProvider = Provider.family.autoDispose<RemoteAsset?, ActionSource>((ref, source) {
final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion.supports(.assetEdits))); final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion.supports(.syncAssetEditsV1)));
if (!isSupported) { if (!isSupported) {
return null; return null;
} }
+94
View File
@@ -30710,6 +30710,98 @@
], ],
"type": "object" "type": "object"
}, },
"SyncAuthUserV2": {
"properties": {
"avatarColor": {
"allOf": [
{
"$ref": "#/components/schemas/UserAvatarColor"
}
],
"nullable": true
},
"deletedAt": {
"description": "User deleted at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
},
"email": {
"description": "User email",
"type": "string"
},
"hasProfileImage": {
"description": "User has profile image",
"type": "boolean"
},
"id": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isAdmin": {
"description": "User is admin",
"type": "boolean"
},
"name": {
"description": "User name",
"type": "string"
},
"oauthId": {
"description": "User OAuth ID",
"nullable": true,
"type": "string"
},
"pinCode": {
"description": "User pin code",
"nullable": true,
"type": "string"
},
"profileChangedAt": {
"description": "User profile changed at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
},
"quotaSizeInBytes": {
"description": "Quota size in bytes",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true,
"type": "integer"
},
"quotaUsageInBytes": {
"description": "Quota usage in bytes",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"storageLabel": {
"description": "User storage label",
"nullable": true,
"type": "string"
}
},
"required": [
"deletedAt",
"email",
"hasProfileImage",
"id",
"isAdmin",
"name",
"oauthId",
"pinCode",
"profileChangedAt",
"quotaSizeInBytes",
"quotaUsageInBytes",
"storageLabel"
],
"type": "object"
},
"SyncCompleteV1": { "SyncCompleteV1": {
"properties": {}, "properties": {},
"type": "object" "type": "object"
@@ -30718,6 +30810,7 @@
"description": "Sync entity type", "description": "Sync entity type",
"enum": [ "enum": [
"AuthUserV1", "AuthUserV1",
"AuthUserV2",
"UserV1", "UserV1",
"UserDeleteV1", "UserDeleteV1",
"AssetV1", "AssetV1",
@@ -31082,6 +31175,7 @@
"AssetMetadataV1", "AssetMetadataV1",
"AssetOcrV1", "AssetOcrV1",
"AuthUsersV1", "AuthUsersV1",
"AuthUsersV2",
"MemoriesV1", "MemoriesV1",
"MemoryToAssetsV1", "MemoryToAssetsV1",
"PartnersV1", "PartnersV1",
+29
View File
@@ -3594,6 +3594,33 @@ export type SyncAuthUserV1 = {
/** User storage label */ /** User storage label */
storageLabel: string | null; storageLabel: string | null;
}; };
export type SyncAuthUserV2 = {
avatarColor?: (UserAvatarColor) | null;
/** User deleted at */
deletedAt: string | null;
/** User email */
email: string;
/** User has profile image */
hasProfileImage: boolean;
/** User ID */
id: string;
/** User is admin */
isAdmin: boolean;
/** User name */
name: string;
/** User OAuth ID */
oauthId: string | null;
/** User pin code */
pinCode: string | null;
/** User profile changed at */
profileChangedAt: string;
/** Quota size in bytes */
quotaSizeInBytes: number | null;
/** Quota usage in bytes */
quotaUsageInBytes: number;
/** User storage label */
storageLabel: string | null;
};
export type SyncCompleteV1 = {}; export type SyncCompleteV1 = {};
export type SyncMemoryAssetDeleteV1 = { export type SyncMemoryAssetDeleteV1 = {
/** Asset ID */ /** Asset ID */
@@ -8306,6 +8333,7 @@ export enum AssetIdErrorReason {
} }
export enum SyncEntityType { export enum SyncEntityType {
AuthUserV1 = "AuthUserV1", AuthUserV1 = "AuthUserV1",
AuthUserV2 = "AuthUserV2",
UserV1 = "UserV1", UserV1 = "UserV1",
UserDeleteV1 = "UserDeleteV1", UserDeleteV1 = "UserDeleteV1",
AssetV1 = "AssetV1", AssetV1 = "AssetV1",
@@ -8380,6 +8408,7 @@ export enum SyncRequestType {
AssetMetadataV1 = "AssetMetadataV1", AssetMetadataV1 = "AssetMetadataV1",
AssetOcrV1 = "AssetOcrV1", AssetOcrV1 = "AssetOcrV1",
AuthUsersV1 = "AuthUsersV1", AuthUsersV1 = "AuthUsersV1",
AuthUsersV2 = "AuthUsersV2",
MemoriesV1 = "MemoriesV1", MemoriesV1 = "MemoriesV1",
MemoryToAssetsV1 = "MemoryToAssetsV1", MemoryToAssetsV1 = "MemoryToAssetsV1",
PartnersV1 = "PartnersV1", PartnersV1 = "PartnersV1",
+1 -1
View File
@@ -140,7 +140,7 @@ export type UserAdmin = User & {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
deletedAt: Date | null; deletedAt: Date | null;
oauthId: string; oauthId: string | null;
quotaSizeInBytes: number | null; quotaSizeInBytes: number | null;
quotaUsageInBytes: number; quotaUsageInBytes: number;
status: UserStatus; status: UserStatus;
+7
View File
@@ -40,6 +40,10 @@ const SyncAuthUserV1Schema = SyncUserV1Schema.merge(
}), }),
).meta({ id: 'SyncAuthUserV1' }); ).meta({ id: 'SyncAuthUserV1' });
const SyncAuthUserV2Schema = SyncAuthUserV1Schema.extend({
oauthId: z.string().nullable().describe('User OAuth ID'),
}).meta({ id: 'SyncAuthUserV2' });
const SyncUserDeleteV1Schema = z.object({ userId: z.uuidv4().describe('User ID') }).meta({ id: 'SyncUserDeleteV1' }); const SyncUserDeleteV1Schema = z.object({ userId: z.uuidv4().describe('User ID') }).meta({ id: 'SyncUserDeleteV1' });
const SyncPartnerV1Schema = z const SyncPartnerV1Schema = z
@@ -112,6 +116,8 @@ class SyncUserV1 extends createZodDto(SyncUserV1Schema) {}
@ExtraModel() @ExtraModel()
class SyncAuthUserV1 extends createZodDto(SyncAuthUserV1Schema) {} class SyncAuthUserV1 extends createZodDto(SyncAuthUserV1Schema) {}
@ExtraModel() @ExtraModel()
class SyncAuthUserV2 extends createZodDto(SyncAuthUserV2Schema) {}
@ExtraModel()
class SyncUserDeleteV1 extends createZodDto(SyncUserDeleteV1Schema) {} class SyncUserDeleteV1 extends createZodDto(SyncUserDeleteV1Schema) {}
@ExtraModel() @ExtraModel()
class SyncPartnerV1 extends createZodDto(SyncPartnerV1Schema) {} class SyncPartnerV1 extends createZodDto(SyncPartnerV1Schema) {}
@@ -463,6 +469,7 @@ class SyncCompleteV1 extends createZodDto(SyncCompleteV1Schema) {}
export type SyncItem = { export type SyncItem = {
[SyncEntityType.AuthUserV1]: SyncAuthUserV1; [SyncEntityType.AuthUserV1]: SyncAuthUserV1;
[SyncEntityType.AuthUserV2]: SyncAuthUserV2;
[SyncEntityType.UserV1]: SyncUserV1; [SyncEntityType.UserV1]: SyncUserV1;
[SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
[SyncEntityType.PartnerV1]: SyncPartnerV1; [SyncEntityType.PartnerV1]: SyncPartnerV1;
+2 -1
View File
@@ -151,7 +151,8 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
createdAt: entity.createdAt, createdAt: entity.createdAt,
deletedAt: entity.deletedAt, deletedAt: entity.deletedAt,
updatedAt: entity.updatedAt, updatedAt: entity.updatedAt,
oauthId: entity.oauthId, // TODO(v4): remove the mapping and make `oauthId` nullable
oauthId: entity.oauthId ?? '',
quotaSizeInBytes: entity.quotaSizeInBytes, quotaSizeInBytes: entity.quotaSizeInBytes,
quotaUsageInBytes: entity.quotaUsageInBytes, quotaUsageInBytes: entity.quotaUsageInBytes,
status: entity.status, status: entity.status,
+2
View File
@@ -1024,6 +1024,7 @@ export enum SyncRequestType {
AssetMetadataV1 = 'AssetMetadataV1', AssetMetadataV1 = 'AssetMetadataV1',
AssetOcrV1 = 'AssetOcrV1', AssetOcrV1 = 'AssetOcrV1',
AuthUsersV1 = 'AuthUsersV1', AuthUsersV1 = 'AuthUsersV1',
AuthUsersV2 = 'AuthUsersV2',
MemoriesV1 = 'MemoriesV1', MemoriesV1 = 'MemoriesV1',
MemoryToAssetsV1 = 'MemoryToAssetsV1', MemoryToAssetsV1 = 'MemoryToAssetsV1',
PartnersV1 = 'PartnersV1', PartnersV1 = 'PartnersV1',
@@ -1048,6 +1049,7 @@ export const SyncRequestTypeSchema = z
export enum SyncEntityType { export enum SyncEntityType {
AuthUserV1 = 'AuthUserV1', AuthUserV1 = 'AuthUserV1',
AuthUserV2 = 'AuthUserV2',
UserV1 = 'UserV1', UserV1 = 'UserV1',
UserDeleteV1 = 'UserDeleteV1', UserDeleteV1 = 'UserDeleteV1',
+1 -1
View File
@@ -130,7 +130,7 @@ type UserEvent = {
isAdmin: boolean; isAdmin: boolean;
shouldChangePassword: boolean; shouldChangePassword: boolean;
avatarColor: UserAvatarColor | null; avatarColor: UserAvatarColor | null;
oauthId: string; oauthId: string | null;
storageLabel: string | null; storageLabel: string | null;
quotaSizeInBytes: number | null; quotaSizeInBytes: number | null;
quotaUsageInBytes: number; quotaUsageInBytes: number;
@@ -0,0 +1,13 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "user" ALTER COLUMN "oauthId" DROP NOT NULL;`.execute(db);
await sql`ALTER TABLE "user" ALTER COLUMN "oauthId" SET DEFAULT NULL;`.execute(db);
await sql`UPDATE "user" SET "oauthId" = NULL WHERE "oauthId" = '';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`UPDATE "user" SET "oauthId" = '' WHERE "oauthId" IS NULL;`.execute(db);
await sql`ALTER TABLE "user" ALTER COLUMN "oauthId" SET DEFAULT '';`.execute(db);
await sql`ALTER TABLE "user" ALTER COLUMN "oauthId" SET NOT NULL;`.execute(db);
}
+1
View File
@@ -94,3 +94,4 @@
1786972746372-AssetOcrSyncReset 1786972746372-AssetOcrSyncReset
1787148183729-ClusterGroups 1787148183729-ClusterGroups
1787148183730-DeleteMismatchedMemoryAssets 1787148183730-DeleteMismatchedMemoryAssets
1789419229196-ConvertUserOAuthIdEmptyStringToNull
+2 -2
View File
@@ -57,8 +57,8 @@ export class UserTable {
@DeleteDateColumn() @DeleteDateColumn()
deletedAt!: Timestamp | null; deletedAt!: Timestamp | null;
@Column({ default: '' }) @Column({ nullable: true })
oauthId!: Generated<string>; oauthId!: string | null;
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Generated<Timestamp>; updatedAt!: Generated<Timestamp>;
+1 -2
View File
@@ -5,7 +5,6 @@ import { BaseService } from 'src/services/base.service.js';
@Injectable() @Injectable()
export class AuthAdminService extends BaseService { export class AuthAdminService extends BaseService {
async unlinkAll(_auth: AuthDto) { async unlinkAll(_auth: AuthDto) {
// TODO replace '' with null await this.userRepository.updateAll({ oauthId: null });
await this.userRepository.updateAll({ oauthId: '' });
} }
} }
+14 -10
View File
@@ -1057,12 +1057,13 @@ describe(AuthService.name, () => {
}); });
it('should not sync the profile picture if the user already has one', async () => { it('should not sync the profile picture if the user already has one', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' }); const oauthId = 'oauth-id';
const user = UserFactory.create({ oauthId, profileImagePath: 'not-empty' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ profile: OAuthProfileFactory.create({
sub: user.oauthId, sub: oauthId,
email: user.email, email: user.email,
picture: 'https://auth.immich.cloud/profiles/1.jpg', picture: 'https://auth.immich.cloud/profiles/1.jpg',
}), }),
@@ -1181,11 +1182,12 @@ describe(AuthService.name, () => {
}); });
it('should promote an existing user to admin if the role claim contains admin on login', async () => { it('should promote an existing user to admin if the role claim contains admin on login', async () => {
const user = UserFactory.create({ isAdmin: false, oauthId: 'oauth-id' }); const oauthId = 'oauth-id';
const user = UserFactory.create({ isAdmin: false, oauthId });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId, immich_role: 'admin' }), profile: OAuthProfileFactory.create({ sub: oauthId, immich_role: 'admin' }),
}); });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, isAdmin: true }); mocks.user.update.mockResolvedValue({ ...user, isAdmin: true });
@@ -1201,11 +1203,12 @@ describe(AuthService.name, () => {
}); });
it('should demote an existing admin if the role claim only contains user on login', async () => { it('should demote an existing admin if the role claim only contains user on login', async () => {
const user = UserFactory.create({ isAdmin: true, oauthId: 'oauth-id' }); const oauthId = 'oauth-id';
const user = UserFactory.create({ isAdmin: true, oauthId });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId, immich_role: ['user'] }), profile: OAuthProfileFactory.create({ sub: oauthId, immich_role: ['user'] }),
}); });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, isAdmin: false }); mocks.user.update.mockResolvedValue({ ...user, isAdmin: false });
@@ -1221,11 +1224,12 @@ describe(AuthService.name, () => {
}); });
it('should not change isAdmin for an existing user if the role claim is blank', async () => { it('should not change isAdmin for an existing user if the role claim is blank', async () => {
const user = UserFactory.create({ isAdmin: true, oauthId: 'oauth-id' }); const oauthId = 'oauth-id';
const user = UserFactory.create({ isAdmin: true, oauthId });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId }), profile: OAuthProfileFactory.create({ sub: oauthId }),
}); });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -1334,7 +1338,7 @@ describe(AuthService.name, () => {
await sut.unlink(auth); await sut.unlink(auth);
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' }); expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: null });
}); });
it('should unlink an account and remove the OAuth data from the session', async () => { it('should unlink an account and remove the OAuth data from the session', async () => {
@@ -1349,7 +1353,7 @@ describe(AuthService.name, () => {
await sut.unlink(auth); await sut.unlink(auth);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null, oauthBearerToken: null }); expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null, oauthBearerToken: null });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' }); expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: null });
}); });
}); });
+1 -1
View File
@@ -441,7 +441,7 @@ export class AuthService extends BaseService {
await this.sessionRepository.update(auth.session.id, { oauthSid: null, oauthBearerToken: null }); await this.sessionRepository.update(auth.session.id, { oauthSid: null, oauthBearerToken: null });
} }
const user = await this.userRepository.update(auth.user.id, { oauthId: '' }); const user = await this.userRepository.update(auth.user.id, { oauthId: null });
return mapUserAdmin(user); return mapUserAdmin(user);
} }
+14
View File
@@ -63,6 +63,7 @@ const sendEntityBackfillCompleteAck = async (response: Writable, ackType: SyncEn
export const SYNC_TYPES_ORDER = [ export const SYNC_TYPES_ORDER = [
SyncRequestType.AuthUsersV1, SyncRequestType.AuthUsersV1,
SyncRequestType.AuthUsersV2,
SyncRequestType.UsersV1, SyncRequestType.UsersV1,
SyncRequestType.PartnersV1, SyncRequestType.PartnersV1,
SyncRequestType.AssetsV1, SyncRequestType.AssetsV1,
@@ -190,6 +191,7 @@ export class SyncService extends BaseService {
[SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(), [SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(),
[SyncRequestType.AuthUsersV1]: () => this.syncAuthUsersV1(options, response, checkpointMap), [SyncRequestType.AuthUsersV1]: () => this.syncAuthUsersV1(options, response, checkpointMap),
[SyncRequestType.AuthUsersV2]: () => this.syncAuthUsersV2(options, response, checkpointMap),
[SyncRequestType.UsersV1]: () => this.syncUsersV1(options, response, checkpointMap), [SyncRequestType.UsersV1]: () => this.syncUsersV1(options, response, checkpointMap),
[SyncRequestType.PartnersV1]: () => this.syncPartnersV1(options, response, checkpointMap), [SyncRequestType.PartnersV1]: () => this.syncPartnersV1(options, response, checkpointMap),
[SyncRequestType.AssetsV2]: () => this.syncAssetsV2(options, response, checkpointMap), [SyncRequestType.AssetsV2]: () => this.syncAssetsV2(options, response, checkpointMap),
@@ -266,6 +268,18 @@ export class SyncService extends BaseService {
private async syncAuthUsersV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { private async syncAuthUsersV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const upsertType = SyncEntityType.AuthUserV1; const upsertType = SyncEntityType.AuthUserV1;
const upserts = this.syncRepository.authUser.getUpserts({ ...options, ack: checkpointMap[upsertType] }); const upserts = this.syncRepository.authUser.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, profileImagePath, ...data } of upserts) {
await send(response, {
type: upsertType,
ids: [updateId],
data: { ...data, oauthId: data.oauthId ?? '', hasProfileImage: !!profileImagePath },
});
}
}
private async syncAuthUsersV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const upsertType = SyncEntityType.AuthUserV2;
const upserts = this.syncRepository.authUser.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, profileImagePath, ...data } of upserts) { for await (const { updateId, profileImagePath, ...data } of upserts) {
await send(response, { await send(response, {
type: upsertType, type: upsertType,
+1 -1
View File
@@ -27,7 +27,7 @@ export class UserFactory {
shouldChangePassword: false, shouldChangePassword: false,
avatarColor: null, avatarColor: null,
deletedAt: null, deletedAt: null,
oauthId: '', oauthId: null,
updatedAt: newDate(), updatedAt: newDate(),
storageLabel: null, storageLabel: null,
name: 'Test User', name: 'Test User',
+2 -2
View File
@@ -11,7 +11,7 @@ export const userStub = {
name: 'admin_name', name: 'admin_name',
id: 'admin_id', id: 'admin_id',
storageLabel: 'admin', storageLabel: 'admin',
oauthId: '', oauthId: null,
shouldChangePassword: false, shouldChangePassword: false,
avatarColor: null, avatarColor: null,
profileImagePath: '', profileImagePath: '',
@@ -29,7 +29,7 @@ export const userStub = {
profileChangedAt: new Date('2021-01-01'), profileChangedAt: new Date('2021-01-01'),
name: 'immich_name', name: 'immich_name',
storageLabel: null, storageLabel: null,
oauthId: '', oauthId: null,
shouldChangePassword: false, shouldChangePassword: false,
avatarColor: null, avatarColor: null,
profileImagePath: '', profileImagePath: '',
+1 -1
View File
@@ -761,7 +761,7 @@ const userInsert = (user: Partial<Insertable<UserTable>> & { clusterGroupId: str
shouldChangePassword: true, shouldChangePassword: true,
storageLabel: null, storageLabel: null,
pinCode: null, pinCode: null,
oauthId: '', oauthId: null,
avatarColor: null, avatarColor: null,
quotaSizeInBytes: null, quotaSizeInBytes: null,
quotaUsageInBytes: 0, quotaUsageInBytes: 0,
@@ -31,7 +31,7 @@ describe(AuthAdminService.name, () => {
await expect(sut.unlinkAll(auth)).resolves.toBeUndefined(); await expect(sut.unlinkAll(auth)).resolves.toBeUndefined();
await expect(userRepo.get(user.id, { withDeleted: true })).resolves.toEqual( await expect(userRepo.get(user.id, { withDeleted: true })).resolves.toEqual(
expect.objectContaining({ oauthId: '' }), expect.objectContaining({ oauthId: null }),
); );
}); });
@@ -43,7 +43,7 @@ describe(AuthAdminService.name, () => {
await expect(sut.unlinkAll(auth)).resolves.toBeUndefined(); await expect(sut.unlinkAll(auth)).resolves.toBeUndefined();
await expect(userRepo.get(user.id, { withDeleted: true })).resolves.toEqual( await expect(userRepo.get(user.id, { withDeleted: true })).resolves.toEqual(
expect.objectContaining({ oauthId: '' }), expect.objectContaining({ oauthId: null }),
); );
}); });
@@ -56,10 +56,10 @@ describe(AuthAdminService.name, () => {
await expect(sut.unlinkAll(auth)).resolves.toBeUndefined(); await expect(sut.unlinkAll(auth)).resolves.toBeUndefined();
await expect(userRepo.get(user1.id, { withDeleted: true })).resolves.toEqual( await expect(userRepo.get(user1.id, { withDeleted: true })).resolves.toEqual(
expect.objectContaining({ oauthId: '' }), expect.objectContaining({ oauthId: null }),
); );
await expect(userRepo.get(user2.id, { withDeleted: true })).resolves.toEqual( await expect(userRepo.get(user2.id, { withDeleted: true })).resolves.toEqual(
expect.objectContaining({ oauthId: '' }), expect.objectContaining({ oauthId: null }),
); );
}); });
}); });
@@ -17,11 +17,11 @@ beforeAll(async () => {
defaultDatabase = await getKyselyDB(); defaultDatabase = await getKyselyDB();
}); });
describe(SyncEntityType.AuthUserV1, () => { describe(SyncEntityType.AuthUserV2, () => {
it('should detect and sync the first user', async () => { it('should detect and sync the first user', async () => {
const { auth, user, ctx } = await setup(await getKyselyDB()); const { auth, user, ctx } = await setup(await getKyselyDB());
const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]); const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV2]);
expect(response).toEqual([ expect(response).toEqual([
{ {
ack: expect.any(String), ack: expect.any(String),
@@ -40,13 +40,13 @@ describe(SyncEntityType.AuthUserV1, () => {
quotaUsageInBytes: user.quotaUsageInBytes, quotaUsageInBytes: user.quotaUsageInBytes,
storageLabel: user.storageLabel, storageLabel: user.storageLabel,
}, },
type: 'AuthUserV1', type: 'AuthUserV2',
}, },
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]); ]);
await ctx.syncAckAll(auth, response); await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AuthUsersV1]); await ctx.assertSyncIsComplete(auth, [SyncRequestType.AuthUsersV2]);
}); });
it('should sync a change and then another change to that same user', async () => { it('should sync a change and then another change to that same user', async () => {
@@ -54,7 +54,7 @@ describe(SyncEntityType.AuthUserV1, () => {
const userRepo = ctx.get(UserRepository); const userRepo = ctx.get(UserRepository);
const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]); const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV2]);
expect(response).toEqual([ expect(response).toEqual([
{ {
ack: expect.any(String), ack: expect.any(String),
@@ -62,7 +62,7 @@ describe(SyncEntityType.AuthUserV1, () => {
id: user.id, id: user.id,
isAdmin: false, isAdmin: false,
}), }),
type: 'AuthUserV1', type: 'AuthUserV2',
}, },
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]); ]);
@@ -71,7 +71,7 @@ describe(SyncEntityType.AuthUserV1, () => {
await userRepo.update(user.id, { isAdmin: true }); await userRepo.update(user.id, { isAdmin: true });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]); const newResponse = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV2]);
expect(newResponse).toEqual([ expect(newResponse).toEqual([
{ {
ack: expect.any(String), ack: expect.any(String),
@@ -79,7 +79,7 @@ describe(SyncEntityType.AuthUserV1, () => {
id: user.id, id: user.id,
isAdmin: true, isAdmin: true,
}), }),
type: 'AuthUserV1', type: 'AuthUserV2',
}, },
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]); ]);
@@ -90,7 +90,7 @@ describe(SyncEntityType.AuthUserV1, () => {
await ctx.newUser(); await ctx.newUser();
const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]); const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV2]);
expect(response).toEqual([ expect(response).toEqual([
{ {
ack: expect.any(String), ack: expect.any(String),
@@ -98,7 +98,7 @@ describe(SyncEntityType.AuthUserV1, () => {
id: user.id, id: user.id,
isAdmin: false, isAdmin: false,
}), }),
type: 'AuthUserV1', type: 'AuthUserV2',
}, },
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]); ]);
+1 -1
View File
@@ -120,7 +120,7 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
createdAt = newDate(), createdAt = newDate(),
updatedAt = newDate(), updatedAt = newDate(),
deletedAt = null, deletedAt = null,
oauthId = '', oauthId = null,
quotaSizeInBytes = null, quotaSizeInBytes = null,
quotaUsageInBytes = 0, quotaUsageInBytes = 0,
status = UserStatus.Active, status = UserStatus.Active,