fix: harden rider social privacy and security
This commit is contained in:
parent
250c37711a
commit
0c2e1a0cac
|
|
@ -186,3 +186,189 @@ describe('MotorCloud ride ownership rules', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Social Architecture Security Rules', () => {
|
||||
test('public profile creation, reading, and privacy protection', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
// Alice creates public profile
|
||||
await assertSucceeds(setDoc(doc(alice, 'public_profiles/alice'), {
|
||||
id: 'alice',
|
||||
username: 'alice_apex',
|
||||
displayName: 'Alice Apex',
|
||||
avatarPreset: 'helmet_cyan',
|
||||
bio: 'Apex hunter',
|
||||
location: 'Atlanta',
|
||||
isSearchableByHandle: true,
|
||||
isProfilePublic: true,
|
||||
allowFriendRequests: true,
|
||||
}));
|
||||
|
||||
// Bob can read Alice's public profile
|
||||
await assertSucceeds(getDoc(doc(bob, 'public_profiles/alice')));
|
||||
|
||||
// Bob CANNOT edit Alice's public profile
|
||||
await assertFails(updateDoc(doc(bob, 'public_profiles/alice'), { bio: 'Hacked' }));
|
||||
|
||||
// Bob CANNOT list all public profiles
|
||||
await assertFails(getDocs(collection(bob, 'public_profiles')));
|
||||
|
||||
// Alice sets isSearchableByHandle = false
|
||||
await assertSucceeds(updateDoc(doc(alice, 'public_profiles/alice'), {
|
||||
isSearchableByHandle: false,
|
||||
}));
|
||||
|
||||
// Bob can no longer read Alice's non-searchable profile since they are not friends
|
||||
await assertFails(getDoc(doc(bob, 'public_profiles/alice')));
|
||||
});
|
||||
|
||||
test('handle reservation uniqueness and directory scraping prevention', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
// Alice creates public profile first so handle lookup can check searchability
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await setDoc(doc(db, 'public_profiles/alice'), {
|
||||
id: 'alice',
|
||||
username: 'alice_apex',
|
||||
isSearchableByHandle: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Alice claims handle
|
||||
await assertSucceeds(setDoc(doc(alice, 'handles/alice_apex'), {
|
||||
uid: 'alice',
|
||||
handle: 'alice_apex',
|
||||
}));
|
||||
|
||||
// Bob CANNOT overwrite Alice's handle
|
||||
await assertFails(setDoc(doc(bob, 'handles/alice_apex'), {
|
||||
uid: 'bob',
|
||||
handle: 'alice_apex',
|
||||
}));
|
||||
|
||||
// Bob CANNOT delete Alice's handle
|
||||
await assertFails(deleteDoc(doc(bob, 'handles/alice_apex')));
|
||||
|
||||
// Bob CANNOT list all handles
|
||||
await assertFails(getDocs(collection(bob, 'handles')));
|
||||
});
|
||||
|
||||
test('friend requests: legitimate vs forged vs blocked vs disabled', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
// Seed Bob's public profile accepting requests
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await setDoc(doc(db, 'public_profiles/bob'), {
|
||||
id: 'bob',
|
||||
username: 'bob_rider',
|
||||
allowFriendRequests: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Alice sends legitimate friend request to Bob
|
||||
await assertSucceeds(setDoc(doc(alice, 'users/bob/friend_requests_incoming/alice'), {
|
||||
receiverUid: 'bob',
|
||||
senderUid: 'alice',
|
||||
senderHandle: 'alice_apex',
|
||||
}));
|
||||
|
||||
// Alice CANNOT forge request pretending to be Charlie
|
||||
await assertFails(setDoc(doc(alice, 'users/bob/friend_requests_incoming/charlie'), {
|
||||
receiverUid: 'bob',
|
||||
senderUid: 'charlie',
|
||||
senderHandle: 'charlie',
|
||||
}));
|
||||
|
||||
// Alice CANNOT send friend request to herself
|
||||
await assertFails(setDoc(doc(alice, 'users/alice/friend_requests_incoming/alice'), {
|
||||
receiverUid: 'alice',
|
||||
senderUid: 'alice',
|
||||
senderHandle: 'alice',
|
||||
}));
|
||||
});
|
||||
|
||||
test('friendship establishment requires incoming request; unilateral declaration fails', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
// Unilateral friendship: Alice tries to add Bob directly as a friend without incoming request -> FAILS
|
||||
await assertFails(setDoc(doc(alice, 'users/alice/friends/bob'), {
|
||||
uid: 'bob',
|
||||
handle: 'bob_rider',
|
||||
}));
|
||||
|
||||
// Seed incoming request from Alice to Bob
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await setDoc(doc(db, 'users/bob/friend_requests_incoming/alice'), {
|
||||
receiverUid: 'bob',
|
||||
senderUid: 'alice',
|
||||
});
|
||||
});
|
||||
|
||||
// Bob accepts request -> SUCCEEDS because Bob has incoming request from Alice
|
||||
await assertSucceeds(setDoc(doc(bob, 'users/bob/friends/alice'), {
|
||||
uid: 'alice',
|
||||
handle: 'alice_apex',
|
||||
}));
|
||||
|
||||
// Bob can also create Alice's corresponding friend doc in the mutual acceptance
|
||||
await assertSucceeds(setDoc(doc(bob, 'users/alice/friends/bob'), {
|
||||
uid: 'bob',
|
||||
handle: 'bob_rider',
|
||||
}));
|
||||
|
||||
// Outsider Charlie CANNOT delete Alice and Bob's friendship
|
||||
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||
await assertFails(deleteDoc(doc(charlie, 'users/alice/friends/bob')));
|
||||
|
||||
// Either participant (Alice) can remove friend
|
||||
await assertSucceeds(deleteDoc(doc(alice, 'users/alice/friends/bob')));
|
||||
});
|
||||
|
||||
test('blocking prevents requests, hides profiles, and blocks cannot be forged', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
// Seed Alice's public profile
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await setDoc(doc(db, 'public_profiles/alice'), {
|
||||
id: 'alice',
|
||||
username: 'alice_apex',
|
||||
isSearchableByHandle: true,
|
||||
allowFriendRequests: true,
|
||||
});
|
||||
await setDoc(doc(db, 'handles/alice_apex'), {
|
||||
uid: 'alice',
|
||||
handle: 'alice_apex',
|
||||
});
|
||||
});
|
||||
|
||||
// Alice blocks Bob
|
||||
await assertSucceeds(setDoc(doc(alice, 'users/alice/blocks/bob'), {
|
||||
blockedUid: 'bob',
|
||||
}));
|
||||
|
||||
// Bob CANNOT read or delete Alice's block doc
|
||||
await assertFails(getDoc(doc(bob, 'users/alice/blocks/bob')));
|
||||
await assertFails(deleteDoc(doc(bob, 'users/alice/blocks/bob')));
|
||||
|
||||
// Bob CANNOT read Alice's public profile once blocked
|
||||
await assertFails(getDoc(doc(bob, 'public_profiles/alice')));
|
||||
|
||||
// Bob CANNOT get Alice's handle doc once blocked
|
||||
await assertFails(getDoc(doc(bob, 'handles/alice_apex')));
|
||||
|
||||
// Bob CANNOT send friend request to Alice once blocked
|
||||
await assertFails(setDoc(doc(bob, 'users/alice/friend_requests_incoming/bob'), {
|
||||
receiverUid: 'alice',
|
||||
senderUid: 'bob',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,26 @@ service cloud.firestore {
|
|||
]);
|
||||
}
|
||||
|
||||
function validPublicProfileCreate(userId) {
|
||||
return ownsUser(userId)
|
||||
&& request.resource.data.id == userId
|
||||
&& request.resource.data.keys().hasOnly([
|
||||
'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio',
|
||||
'location', 'isSearchableByHandle', 'isProfilePublic', 'allowFriendRequests',
|
||||
'updatedAt'
|
||||
]);
|
||||
}
|
||||
|
||||
function validPublicProfileUpdate(userId) {
|
||||
return ownsUser(userId)
|
||||
&& request.resource.data.id == resource.data.id
|
||||
&& request.resource.data.keys().hasOnly([
|
||||
'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio',
|
||||
'location', 'isSearchableByHandle', 'isProfilePublic', 'allowFriendRequests',
|
||||
'updatedAt'
|
||||
]);
|
||||
}
|
||||
|
||||
function validRideCreate(userId, rideId) {
|
||||
return ownsUser(userId)
|
||||
&& request.resource.data.ownerId == userId
|
||||
|
|
@ -41,8 +61,26 @@ service cloud.firestore {
|
|||
&& request.resource.data.id == resource.data.id;
|
||||
}
|
||||
|
||||
function isBlockedBy(sourceUid, targetUid) {
|
||||
return exists(/databases/$(database)/documents/users/$(targetUid)/blocks/$(sourceUid));
|
||||
}
|
||||
|
||||
function areMutuallyBlocked(userA, userB) {
|
||||
return isBlockedBy(userA, userB) || isBlockedBy(userB, userA);
|
||||
}
|
||||
|
||||
// --- HANDLE RESERVATIONS ---
|
||||
// Internal uniqueness index. Read access is guarded to prevent
|
||||
// turning handle lookup into an open user enumeration directory.
|
||||
match /handles/{handle} {
|
||||
allow read: if signedIn();
|
||||
allow list: if false;
|
||||
allow get: if signedIn() && (
|
||||
resource == null ||
|
||||
resource.data.uid == request.auth.uid || (
|
||||
!areMutuallyBlocked(request.auth.uid, resource.data.uid) &&
|
||||
get(/databases/$(database)/documents/public_profiles/$(resource.data.uid)).data.isSearchableByHandle == true
|
||||
)
|
||||
);
|
||||
allow create: if signedIn()
|
||||
&& request.resource.data.uid == request.auth.uid
|
||||
&& request.resource.data.handle == handle;
|
||||
|
|
@ -50,18 +88,35 @@ service cloud.firestore {
|
|||
allow update: if false;
|
||||
}
|
||||
|
||||
// --- PUBLIC PROFILES ---
|
||||
// Contains ONLY safe public fields. No email, tokens, rides, or billing.
|
||||
match /public_profiles/{userId} {
|
||||
allow list: if false;
|
||||
allow get: if signedIn() && (
|
||||
ownsUser(userId) || (
|
||||
!areMutuallyBlocked(request.auth.uid, userId) &&
|
||||
(
|
||||
resource.data.isSearchableByHandle == true ||
|
||||
exists(/databases/$(database)/documents/users/$(userId)/friends/$(request.auth.uid))
|
||||
)
|
||||
)
|
||||
);
|
||||
allow create: if validPublicProfileCreate(userId);
|
||||
allow update: if validPublicProfileUpdate(userId);
|
||||
allow delete: if ownsUser(userId);
|
||||
}
|
||||
|
||||
// --- PRIVATE USER ACCOUNTS ---
|
||||
// Strictly private to the account owner.
|
||||
match /users/{userId} {
|
||||
allow create: if validUserCreate(userId);
|
||||
allow get: if signedIn();
|
||||
allow list: if ownsUser(userId);
|
||||
allow read: if ownsUser(userId);
|
||||
allow update: if validUserUpdate(userId);
|
||||
allow delete: if ownsUser(userId);
|
||||
|
||||
match /rides/{rideId} {
|
||||
allow create: if validRideCreate(userId, rideId);
|
||||
allow update: if validRideUpdate(userId);
|
||||
// Listing is path-scoped to the authenticated owner. Individual
|
||||
// reads and deletes retain the persisted ownerId check.
|
||||
allow list: if ownsUser(userId);
|
||||
allow get, delete: if ownsUser(userId) && resource.data.ownerId == userId;
|
||||
|
||||
|
|
@ -87,21 +142,44 @@ service cloud.firestore {
|
|||
allow read, write: if ownsUser(userId);
|
||||
}
|
||||
|
||||
// --- SOCIAL SUBCOLLECTIONS ---
|
||||
match /friends/{friendId} {
|
||||
allow read: if ownsUser(userId);
|
||||
allow write: if signedIn() && (request.auth.uid == userId || request.auth.uid == friendId);
|
||||
allow create: if signedIn()
|
||||
&& (request.auth.uid == userId || request.auth.uid == friendId)
|
||||
&& userId != friendId
|
||||
&& !areMutuallyBlocked(userId, friendId)
|
||||
&& (
|
||||
(request.auth.uid == userId && exists(/databases/$(database)/documents/users/$(userId)/friend_requests_incoming/$(friendId))) ||
|
||||
(request.auth.uid == friendId && exists(/databases/$(database)/documents/users/$(friendId)/friend_requests_incoming/$(userId)))
|
||||
);
|
||||
allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == friendId);
|
||||
allow update: if false;
|
||||
}
|
||||
|
||||
match /friend_requests_incoming/{senderId} {
|
||||
allow read: if ownsUser(userId);
|
||||
allow create: if signedIn() && request.auth.uid == senderId && request.resource.data.receiverUid == userId;
|
||||
allow create: if signedIn()
|
||||
&& request.auth.uid == senderId
|
||||
&& senderId != userId
|
||||
&& request.resource.data.receiverUid == userId
|
||||
&& request.resource.data.senderUid == senderId
|
||||
&& !areMutuallyBlocked(userId, senderId)
|
||||
&& !exists(/databases/$(database)/documents/users/$(userId)/friends/$(senderId))
|
||||
&& get(/databases/$(database)/documents/public_profiles/$(userId)).data.allowFriendRequests == true;
|
||||
allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == senderId);
|
||||
allow update: if false;
|
||||
}
|
||||
|
||||
match /friend_requests_outgoing/{receiverId} {
|
||||
allow read: if ownsUser(userId);
|
||||
allow create: if ownsUser(userId) && request.resource.data.receiverUid == receiverId;
|
||||
allow create: if ownsUser(userId)
|
||||
&& receiverId != userId
|
||||
&& request.resource.data.receiverUid == receiverId
|
||||
&& request.resource.data.senderUid == userId
|
||||
&& !areMutuallyBlocked(userId, receiverId);
|
||||
allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == receiverId);
|
||||
allow update: if false;
|
||||
}
|
||||
|
||||
match /blocks/{blockedId} {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,42 @@ class AccountCloudCleanupService implements CloudAccountCleanup {
|
|||
await _deleteCollection(userRef.collection('rides'));
|
||||
await _deleteCollection(userRef.collection('motorcycles'));
|
||||
await _deleteCollection(userRef.collection('trips'));
|
||||
// Clean up reciprocal relationships on other users before deleting own references
|
||||
final friendsSnap = await userRef.collection('friends').get();
|
||||
for (final friendDoc in friendsSnap.docs) {
|
||||
final friendUid = friendDoc.id;
|
||||
final reciprocalFriendRef = firestore
|
||||
.collection('users')
|
||||
.doc(friendUid)
|
||||
.collection('friends')
|
||||
.doc(user.uid);
|
||||
await reciprocalFriendRef.delete().catchError((_) {});
|
||||
}
|
||||
|
||||
final incomingRequestsSnap =
|
||||
await userRef.collection('friend_requests_incoming').get();
|
||||
for (final incDoc in incomingRequestsSnap.docs) {
|
||||
final senderUid = incDoc.id;
|
||||
final reciprocalOutgoingRef = firestore
|
||||
.collection('users')
|
||||
.doc(senderUid)
|
||||
.collection('friend_requests_outgoing')
|
||||
.doc(user.uid);
|
||||
await reciprocalOutgoingRef.delete().catchError((_) {});
|
||||
}
|
||||
|
||||
final outgoingRequestsSnap =
|
||||
await userRef.collection('friend_requests_outgoing').get();
|
||||
for (final outDoc in outgoingRequestsSnap.docs) {
|
||||
final recipientUid = outDoc.id;
|
||||
final reciprocalIncomingRef = firestore
|
||||
.collection('users')
|
||||
.doc(recipientUid)
|
||||
.collection('friend_requests_incoming')
|
||||
.doc(user.uid);
|
||||
await reciprocalIncomingRef.delete().catchError((_) {});
|
||||
}
|
||||
|
||||
await _deleteCollection(userRef.collection('events'));
|
||||
await _deleteCollection(userRef.collection('friends'));
|
||||
await _deleteCollection(userRef.collection('friend_requests_incoming'));
|
||||
|
|
@ -90,6 +126,9 @@ class AccountCloudCleanupService implements CloudAccountCleanup {
|
|||
await firestore.collection('handles').doc(handle.toLowerCase()).delete();
|
||||
}
|
||||
|
||||
// Delete public profile projection
|
||||
await firestore.collection('public_profiles').doc(user.uid).delete().catchError((_) {});
|
||||
|
||||
// Delete the profile last so retries can still discover the account scope.
|
||||
final profileBatch = firestore.batch()..delete(userRef);
|
||||
await profileBatch.commit();
|
||||
|
|
|
|||
|
|
@ -174,6 +174,45 @@ class RiderProfile {
|
|||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
/// Serializes into the minimal, safe public profile projection for `public_profiles/{uid}`.
|
||||
/// Strictly isolates public fields from private user data.
|
||||
Map<String, dynamic> toPublicProfileJson() => {
|
||||
'id': uid,
|
||||
'username': handle,
|
||||
'displayName': displayName,
|
||||
if (avatarUrl != null) 'profilePhotoUrl': avatarUrl,
|
||||
if (avatarPreset != null) 'avatarPreset': avatarPreset,
|
||||
if (bio.isNotEmpty) 'bio': bio,
|
||||
if (region.isNotEmpty) 'location': region,
|
||||
'isSearchableByHandle': privacySettings.isSearchableByHandle,
|
||||
'isProfilePublic': privacySettings.isProfilePublic,
|
||||
'allowFriendRequests': privacySettings.allowFriendRequests,
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory RiderProfile.fromPublicProfileJson(Map<String, dynamic> json) {
|
||||
final updated = json['updatedAt'] != null
|
||||
? DateTime.tryParse(json['updatedAt'] as String) ?? DateTime.now()
|
||||
: DateTime.now();
|
||||
|
||||
return RiderProfile(
|
||||
uid: json['id'] as String? ?? '',
|
||||
handle: (json['username'] as String? ?? '').toLowerCase().trim(),
|
||||
displayName: json['displayName'] as String? ?? '',
|
||||
bio: json['bio'] as String? ?? '',
|
||||
region: json['location'] as String? ?? '',
|
||||
avatarPreset: json['avatarPreset'] as String?,
|
||||
avatarUrl: json['profilePhotoUrl'] as String?,
|
||||
privacySettings: ProfilePrivacySettings(
|
||||
isSearchableByHandle: json['isSearchableByHandle'] as bool? ?? true,
|
||||
isProfilePublic: json['isProfilePublic'] as bool? ?? true,
|
||||
allowFriendRequests: json['allowFriendRequests'] as bool? ?? true,
|
||||
),
|
||||
createdAt: updated,
|
||||
updatedAt: updated,
|
||||
);
|
||||
}
|
||||
|
||||
factory RiderProfile.fromJson(Map<String, dynamic> json) {
|
||||
final created = json['createdAt'] != null
|
||||
? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now()
|
||||
|
|
|
|||
|
|
@ -22,17 +22,34 @@ class RiderProfileService {
|
|||
|
||||
bool get isAvailable => _firestore != null;
|
||||
|
||||
/// Fetches a rider's profile by UID.
|
||||
/// Fetches a rider's profile by UID (reads owner profile, falls back to public profile).
|
||||
Future<RiderProfile?> getProfile(String uid) async {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return null;
|
||||
|
||||
try {
|
||||
final doc = await firestore.collection('users').doc(uid).get();
|
||||
if (!doc.exists || doc.data() == null) return null;
|
||||
if (doc.exists && doc.data() != null) {
|
||||
return RiderProfile.fromJson(doc.data()!);
|
||||
}
|
||||
return await getPublicProfile(uid);
|
||||
} catch (e) {
|
||||
debugPrint('[RiderProfileService] Failed to load profile for $uid: $e');
|
||||
// If reading private user doc fails (e.g. PERMISSION_DENIED for other user), read public projection
|
||||
return await getPublicProfile(uid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the public projection of a rider's profile.
|
||||
Future<RiderProfile?> getPublicProfile(String uid) async {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return null;
|
||||
|
||||
try {
|
||||
final doc = await firestore.collection('public_profiles').doc(uid).get();
|
||||
if (!doc.exists || doc.data() == null) return null;
|
||||
return RiderProfile.fromPublicProfileJson(doc.data()!);
|
||||
} catch (e) {
|
||||
debugPrint('[RiderProfileService] Failed to load public profile for $uid: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -121,6 +138,7 @@ class RiderProfileService {
|
|||
}
|
||||
|
||||
final userRef = firestore.collection('users').doc(uid);
|
||||
final publicRef = firestore.collection('public_profiles').doc(uid);
|
||||
final handleRef = firestore.collection('handles').doc(normalizedHandle);
|
||||
|
||||
// Read current user profile to determine previous handle if changing
|
||||
|
|
@ -134,6 +152,19 @@ class RiderProfileService {
|
|||
final isChangingHandle =
|
||||
previousHandle != null && previousHandle != normalizedHandle;
|
||||
|
||||
final updatedProfile = RiderProfile(
|
||||
uid: uid,
|
||||
handle: normalizedHandle,
|
||||
displayName: displayName.trim().isEmpty ? normalizedHandle : displayName.trim(),
|
||||
bio: bio.trim(),
|
||||
region: region.trim(),
|
||||
avatarPreset: avatarPreset,
|
||||
avatarUrl: avatarUrl,
|
||||
privacySettings: privacySettings ?? existingProfile?.privacySettings ?? const ProfilePrivacySettings(),
|
||||
createdAt: existingProfile?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
await firestore.runTransaction((transaction) async {
|
||||
// 1. Verify handle availability
|
||||
final handleSnapshot = await transaction.get(handleRef);
|
||||
|
|
@ -157,37 +188,15 @@ class RiderProfileService {
|
|||
transaction.delete(oldHandleRef);
|
||||
}
|
||||
|
||||
// 4. Save profile document
|
||||
final updatedProfile = RiderProfile(
|
||||
uid: uid,
|
||||
handle: normalizedHandle,
|
||||
displayName: displayName.trim().isEmpty ? normalizedHandle : displayName.trim(),
|
||||
bio: bio.trim(),
|
||||
region: region.trim(),
|
||||
avatarPreset: avatarPreset,
|
||||
avatarUrl: avatarUrl,
|
||||
privacySettings: privacySettings ?? existingProfile?.privacySettings ?? const ProfilePrivacySettings(),
|
||||
createdAt: existingProfile?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
// 4. Save private user profile document
|
||||
transaction.set(userRef, updatedProfile.toJson(), SetOptions(merge: true));
|
||||
|
||||
// 5. Save public profile projection
|
||||
transaction.set(publicRef, updatedProfile.toPublicProfileJson(), SetOptions(merge: true));
|
||||
});
|
||||
|
||||
final saved = await getProfile(uid);
|
||||
return saved ??
|
||||
RiderProfile(
|
||||
uid: uid,
|
||||
handle: normalizedHandle,
|
||||
displayName: displayName.trim(),
|
||||
bio: bio.trim(),
|
||||
region: region.trim(),
|
||||
avatarPreset: avatarPreset,
|
||||
avatarUrl: avatarUrl,
|
||||
privacySettings: privacySettings ?? const ProfilePrivacySettings(),
|
||||
createdAt: existingProfile?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
);
|
||||
return saved ?? updatedProfile;
|
||||
}
|
||||
|
||||
/// Releases the rider's handle and cleans up the profile doc.
|
||||
|
|
@ -197,6 +206,7 @@ class RiderProfileService {
|
|||
|
||||
try {
|
||||
final userRef = firestore.collection('users').doc(uid);
|
||||
final publicRef = firestore.collection('public_profiles').doc(uid);
|
||||
final doc = await userRef.get();
|
||||
if (doc.exists && doc.data() != null) {
|
||||
final handle = doc.data()?['username'] as String?;
|
||||
|
|
@ -205,6 +215,7 @@ class RiderProfileService {
|
|||
}
|
||||
}
|
||||
await userRef.delete();
|
||||
await publicRef.delete();
|
||||
} catch (e) {
|
||||
debugPrint('[RiderProfileService] Failed to delete profile for $uid: $e');
|
||||
rethrow;
|
||||
|
|
|
|||
|
|
@ -63,6 +63,27 @@ void main() {
|
|||
expect(restored.avatarPreset, equals(profile.avatarPreset));
|
||||
expect(restored.privacySettings.isProfilePublic, isFalse);
|
||||
expect(restored.displayHandle, equals('@apex_pilot'));
|
||||
|
||||
// Public projection test
|
||||
final publicJson = profile.toPublicProfileJson();
|
||||
expect(publicJson['id'], equals('user_123'));
|
||||
expect(publicJson['username'], equals('apex_pilot'));
|
||||
expect(publicJson['displayName'], equals('Apex Pilot'));
|
||||
expect(publicJson['bio'], equals('Weekend track and mountain rider.'));
|
||||
expect(publicJson['location'], equals('North Georgia Mountains'));
|
||||
expect(publicJson['avatarPreset'], equals('helmet_cyan'));
|
||||
expect(publicJson['isSearchableByHandle'], isTrue);
|
||||
expect(publicJson['isProfilePublic'], isFalse);
|
||||
expect(publicJson['allowFriendRequests'], isTrue);
|
||||
// Ensure private keys are never included
|
||||
expect(publicJson.containsKey('email'), isFalse);
|
||||
expect(publicJson.containsKey('rides'), isFalse);
|
||||
|
||||
final fromPublic = RiderProfile.fromPublicProfileJson(publicJson);
|
||||
expect(fromPublic.uid, equals('user_123'));
|
||||
expect(fromPublic.handle, equals('apex_pilot'));
|
||||
expect(fromPublic.displayName, equals('Apex Pilot'));
|
||||
expect(fromPublic.privacySettings.isProfilePublic, isFalse);
|
||||
});
|
||||
|
||||
test('FriendSummary serialization round-trip', () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue