diff --git a/api/Sources/Api/PairQL/Dashboard/Pairs/DeleteEntity.swift b/api/Sources/Api/PairQL/Dashboard/Pairs/DeleteEntity.swift index 5101e290..4422c195 100644 --- a/api/Sources/Api/PairQL/Dashboard/Pairs/DeleteEntity.swift +++ b/api/Sources/Api/PairQL/Dashboard/Pairs/DeleteEntity.swift @@ -88,7 +88,16 @@ extension DeleteEntity: Resolver { .where(.adminId == context.admin.id) .first(in: context.db) dashSecurityEvent(.childDeleted, "name: \(user.name)", in: context) + + let userKeychainIds = try await UserKeychain.query() + .where(.userId == user.id) + .all(in: context.db) + .map(\.keychainId) + try await context.db.delete(user) + + await deleteUnusedEmptyAutogenKeychain(userKeychainIds, context.db) + let devices = try await Device.query() .where(.id |=| deviceIds) .all(in: context.db) @@ -105,6 +114,33 @@ extension DeleteEntity: Resolver { } } +func deleteUnusedEmptyAutogenKeychain( + _ userKeychainIds: [Keychain.Id], + _ db: any DuetSQL.Client +) async { + do { + let keychains = try await Keychain.query() + .where(.id |=| userKeychainIds) + .where(.like(.description, "%created automatically%")) + .all(in: db) + + for keychain in keychains { + let keys = try await keychain.keys(in: db) + if keys.isEmpty { + let otherUsers = try await UserKeychain.query() + .where(.keychainId == keychain.id) + .all(in: db) + if otherUsers.isEmpty { + try await db.delete(keychain) + } + } + } + } catch { + // we don't care about errors, we're just cleaning up + // after ourselves, there's no harm if this operation fails + } +} + // extensions extension DeleteEntity.Input { diff --git a/api/Sources/Api/PairQL/Dashboard/Pairs/SaveUser.swift b/api/Sources/Api/PairQL/Dashboard/Pairs/SaveUser.swift index 547e925c..56c7669c 100644 --- a/api/Sources/Api/PairQL/Dashboard/Pairs/SaveUser.swift +++ b/api/Sources/Api/PairQL/Dashboard/Pairs/SaveUser.swift @@ -27,12 +27,24 @@ extension SaveUser: Resolver { id: input.id, adminId: context.admin.id, name: input.name, - keyloggingEnabled: input.keyloggingEnabled, - screenshotsEnabled: input.screenshotsEnabled, - screenshotsResolution: input.screenshotsResolution, - screenshotsFrequency: input.screenshotsFrequency, - showSuspensionActivity: input.showSuspensionActivity + // vvv--- these are our recommended defaults + keyloggingEnabled: true, + screenshotsEnabled: true, + screenshotsResolution: 1000, + screenshotsFrequency: 180, + showSuspensionActivity: true )) + let keychain = try await context.db.create(Keychain( + authorId: context.admin.id, + name: "\(input.name)’s Keychain", + isPublic: false, + description: """ + This keychain was created automatically as a default place for you to \ + add keys for \(input.name). Feel free to use it as is, change it, \ + delete it, or create as many other keychains as you like. + """ + )) + try await context.db.create(UserKeychain(userId: user.id, keychainId: keychain.id)) dashSecurityEvent(.childAdded, "name: \(user.name)", in: context) } else { user = try await context.db.find(input.id) @@ -47,20 +59,20 @@ extension SaveUser: Resolver { user.screenshotsFrequency = input.screenshotsFrequency user.showSuspensionActivity = input.showSuspensionActivity try await context.db.update(user) - } - let existing = try await user.keychains(in: context.db).map(\.id) - if !existing.elementsEqual(input.keychainIds) { - dashSecurityEvent(.keychainsChanged, "child: \(user.name)", in: context) + let existing = try await user.keychains(in: context.db).map(\.id) + if !existing.elementsEqual(input.keychainIds) { + dashSecurityEvent(.keychainsChanged, "child: \(user.name)", in: context) - try await UserKeychain.query() - .where(.userId == user.id) - .delete(in: context.db) + try await UserKeychain.query() + .where(.userId == user.id) + .delete(in: context.db) - let pivots = input.keychainIds - .map { UserKeychain(userId: user.id, keychainId: $0) } + let pivots = input.keychainIds + .map { UserKeychain(userId: user.id, keychainId: $0) } - try await context.db.create(pivots) + try await context.db.create(pivots) + } } try await with(dependency: \.websockets) diff --git a/api/Tests/ApiTests/DashboardPairResolvers/UsersResolversTests.swift b/api/Tests/ApiTests/DashboardPairResolvers/UsersResolversTests.swift index 4d95d43f..bdd43b81 100644 --- a/api/Tests/ApiTests/DashboardPairResolvers/UsersResolversTests.swift +++ b/api/Tests/ApiTests/DashboardPairResolvers/UsersResolversTests.swift @@ -5,30 +5,18 @@ import XExpect @testable import Api final class UsersResolversTests: ApiTestCase { - func testDeleteUser() async throws { - let user = try await self.user() - let output = try await DeleteEntity.resolve( - with: .init(id: user.id.rawValue, type: .user), - in: user.admin.context - ) - expect(output).toEqual(.success) - let retrieved = try? await self.db.find(user.id) - expect(retrieved).toBeNil() - expect(sent.websocketMessages).toEqual([.init(.userDeleted, to: .user(user.id))]) - } - - func testSaveNewUser() async throws { + func testSaveAndDeleteNewUser() async throws { let admin = try await self.admin() let input = SaveUser.Input( id: .init(), isNew: true, - name: "Test User", - keyloggingEnabled: true, - screenshotsEnabled: true, - screenshotsResolution: 111, - screenshotsFrequency: 588, - showSuspensionActivity: false, + name: "Franny", + keyloggingEnabled: false, // <-- ignored for new user, we set + screenshotsEnabled: false, // <-- ignored for new user, we set + screenshotsResolution: 999, // <-- ignored for new user, we set + screenshotsFrequency: 888, // <-- ignored for new user, we set + showSuspensionActivity: false, // <-- ignored for new user, we set keychainIds: [] ) @@ -36,12 +24,41 @@ final class UsersResolversTests: ApiTestCase { let user = try await self.db.find(input.id) expect(output).toEqual(.success) - expect(user.name).toEqual("Test User") + expect(user.name).toEqual("Franny") + // vvv--- these are our recommended defaults expect(user.keyloggingEnabled).toEqual(true) expect(user.screenshotsEnabled).toEqual(true) - expect(user.screenshotsResolution).toEqual(111) - expect(user.screenshotsFrequency).toEqual(588) - expect(user.showSuspensionActivity).toEqual(false) + expect(user.screenshotsResolution).toEqual(1000) + expect(user.screenshotsFrequency).toEqual(180) + expect(user.showSuspensionActivity).toEqual(true) + + let keychains = try await user.keychains(in: self.db) + expect(keychains.count).toEqual(1) + let keychainId = keychains[0].id + expect(keychains[0].name).toEqual("Franny’s Keychain") + expect(keychains[0].description!).toContain("created automatically") + expect(sent.websocketMessages).toEqual([.init(.userUpdated, to: .user(user.id))]) + + // now delete... + let deleteOutput = try await DeleteEntity.resolve( + with: .init(id: user.id.rawValue, type: .user), + in: admin.context + ) + expect(deleteOutput).toEqual(.success) + let retrieved = try? await self.db.find(user.id) + expect(retrieved).toBeNil() + expect(sent.websocketMessages).toEqual([ + .init(.userUpdated, to: .user(user.id)), + .init(.userDeleted, to: .user(user.id)), + ]) + + // and the empty keychain should be deleted + let userKeychains = try await UserKeychain.query() + .where(.userId == user.id) + .all(in: self.db) + expect(userKeychains.isEmpty).toBeTrue() + let retrievedKeychain = try? await self.db.find(keychainId) + expect(retrievedKeychain).toBeNil() } func testExistingUserUpdated() async throws {