diff --git a/.idea/runConfigurations/dev_watch.xml b/.idea/runConfigurations/dev_watch.xml
new file mode 100644
index 0000000..6b364b6
--- /dev/null
+++ b/.idea/runConfigurations/dev_watch.xml
@@ -0,0 +1,14 @@
+<component name="ProjectRunConfigurationManager">
+ <configuration default="false" name="dev:watch" type="js.build_tools.npm" nameIsGenerated="true">
+ <package-json value="$PROJECT_DIR$/package.json" />
+ <command value="run" />
+ <scripts>
+ <script value="dev:watch" />
+ </scripts>
+ <node-interpreter value="project" />
+ <envs>
+ <env name="LOG_QUERIES" value="false" />
+ </envs>
+ <method v="2" />
+ </configuration>
+</component>
\ No newline at end of file
diff --git a/README.md b/README.md
index adbaeef..1a02eee 100644
--- a/README.md
+++ b/README.md
@@ -15,5 +15,6 @@ Environment variables:
| `PORT` | `3000` | The port the server will run on. |
| `LOG_REQUESTS` | `-` | Requests to log to the console by status, `-` to invert. |
| `DATABASE_SECRET_PATH` | ` ` | The path to the mongodb connection string. |
-| `LOG_QUERIES` | ` ` | Whether to enable mongoose debug logs |
+| `LOG_QUERIES` | `false` | Whether to enable mongoose debug logs |
+| `LOG_AUTH` | `false` | Whether to enable authentication debug logs |
| `JWT_SECRET_PATH` | ` ` | The path to the JWT secret certificate. |
\ No newline at end of file
diff --git a/package.json b/package.json
index 75b797d..bbd9060 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"coverage": "node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info",
"build:http": "node src/api/routes.js | grep '^%' --line-buffered | sed -u 's/^% //g' > endpoints.http",
"dev": "node .",
- "dev:watch": "npx -y nodemon"
+ "dev:watch": "npx -y nodemon --ignore testFrontend/"
},
"repository": {
"type": "git",
diff --git a/src/api/middlewares/authMiddleware.js b/src/api/middlewares/authMiddleware.js
index b91449f..34a96f4 100644
--- a/src/api/middlewares/authMiddleware.js
+++ b/src/api/middlewares/authMiddleware.js
@@ -3,7 +3,7 @@ import { DbUser, UserType } from '#db/schemas/index.js';
import { SafeNSoundError } from '#util/error.js';
import { getUserById } from '#db/dbAccess/index.js';
-const shouldLogAuth = !!process.env['LOG_AUTH'];
+const shouldLogAuth = process.env['LOG_AUTH'] === 'true';
function logAuth(...params) {
if (shouldLogAuth) {
console.log('[AUTH]', ...params);
@@ -33,10 +33,25 @@ export async function useAuthentication(req, res, next) {
));
logAuth('Token data:', auth);
- req.user = await getUserById(auth.sub);
- logAuth('User data:', req.user);
+ if (auth) {
+ req.user = await getUserById(auth.sub);
+ logAuth('User data:', req.user);
- req.device = req.user.devices.find(device => device.id === auth.deviceId);
+ if (req.user) {
+ req.device = req.user.devices.find(
+ device => device.id === auth.deviceId
+ );
+ logAuth('Device data:', req.device);
+
+ if (req.device) {
+ if (req.device.lastSeen < Date.now() - 1000) {
+ logAuth('Updating device last seen for', req.device.id);
+ req.device.lastSeen = Date.now();
+ await req.user.save();
+ }
+ }
+ }
+ }
next();
}
diff --git a/src/api/middlewares/errorMiddleware.js b/src/api/middlewares/errorMiddleware.js
index d66c31d..2ca31db 100644
--- a/src/api/middlewares/errorMiddleware.js
+++ b/src/api/middlewares/errorMiddleware.js
@@ -22,7 +22,6 @@ export function handleErrors(err, req, res, _next) {
}
if (err instanceof SafeNSoundError) {
- console.log('meow');
console.error(err.stack);
res.status(500).json(err);
} else {
diff --git a/src/api/routes/adminRoutes.js b/src/api/routes/adminRoutes.js
new file mode 100644
index 0000000..7a1d1e2
--- /dev/null
+++ b/src/api/routes/adminRoutes.js
@@ -0,0 +1,55 @@
+import {
+ requireMonitor,
+ requireUser,
+ requireRole,
+ requireAdmin
+} from '#api/middlewares/index.js';
+import { DbUser, UserType } from '#db/schemas/index.js';
+import { RouteMethod } from '#api/RouteDescription.js';
+import { getUserById } from '#db/dbAccess/index.js';
+import { AlarmDto } from '#dto/AlarmDto.js';
+
+/**
+ * @type {RouteDescription}
+ */
+export const adminGetUserIdsRoute = {
+ path: '/admin/allUserIds',
+ methods: {
+ get: new RouteMethod({
+ middlewares: [requireAdmin],
+ description: 'Get all user IDs',
+ async method(req, res) {
+ // streaming json array
+ res.status(200);
+ res.write('[\n');
+
+ const users = DbUser.find().lean().cursor();
+ for await (const user of users) {
+ res.write(JSON.stringify(user._id) + ',\n');
+ }
+
+ res.write(']');
+ res.end();
+ }
+ })
+ }
+};
+export const adminMonitorAllRoute = {
+ path: '/admin/monitorAllUsers',
+ methods: {
+ post: new RouteMethod({
+ middlewares: [requireAdmin],
+ description: 'Monitor all users',
+ async method(req, res) {
+ const users = await DbUser.find({ type: UserType.USER }).lean();
+ const monitoredUsers = users.map(user => user._id);
+
+ // Update the admin's monitoredUsers
+ req.user.monitoredUsers = monitoredUsers;
+ await req.user.save();
+
+ res.status(204).send();
+ }
+ })
+ }
+};
diff --git a/src/api/routes/alarmRoutes.js b/src/api/routes/alarmRoutes.js
index 23b79c1..ae5a88a 100644
--- a/src/api/routes/alarmRoutes.js
+++ b/src/api/routes/alarmRoutes.js
@@ -3,7 +3,7 @@ import {
requireUser,
requireRole
} from '#api/middlewares/index.js';
-import { UserType } from '#db/schemas/index.js';
+import { DbUser, UserType } from '#db/schemas/index.js';
import { RouteMethod } from '#api/RouteDescription.js';
import { getUserById } from '#db/dbAccess/index.js';
import { AlarmDto } from '#dto/AlarmDto.js';
@@ -12,7 +12,7 @@ import { AlarmDto } from '#dto/AlarmDto.js';
* @type {RouteDescription}
*/
export const alarmByUserRoute = {
- path: '/alarm/:id',
+ path: '/user/:id/alarm',
methods: {
get: new RouteMethod({
middlewares: [requireMonitor],
@@ -27,8 +27,10 @@ export const alarmByUserRoute = {
description: 'Clear the alarm for a monitored user',
async method(req, res) {
const user = await getUserById(req.params.id);
- user.alarm = null;
- await user.save();
+ if (user.alarm) {
+ user.alarm = null;
+ await user.save();
+ }
res.status(204).send();
}
})
@@ -45,15 +47,24 @@ export const alarmListRoute = {
middlewares: [requireMonitor],
description: 'Get a list of all alarms for monitored users',
async method(req, res) {
- console.log(req.user.monitoredUsers);
- const alarms = {};
- for (const userId of req.user.monitoredUsers) {
- const user = await getUserById(userId);
+ // execute the query asynchronously and manually construct a response, for scaling reasons
+ const users = DbUser.find({
+ _id: { $in: req.user.monitoredUsers }
+ })
+ .lean()
+ .cursor();
+ res.status(200);
+ res.write('{\n');
+ for await (const user of users) {
if (user.alarm) {
- alarms[userId] = user.alarm;
+ // alarms[user._id] = user.alarm;
+ res.write(
+ `"${user._id}": ${JSON.stringify(user.alarm)},\n`
+ );
}
}
- res.send(alarms);
+ res.write('}');
+ res.end();
}
})
}
@@ -76,7 +87,6 @@ export const alarmRoute = {
middlewares: [requireUser],
description: 'Raise an alarm',
async method(req, res) {
- console.log(req.body);
req.user.alarm = await AlarmDto.create(req.body);
await req.user.save();
res.status(204).send();
@@ -86,8 +96,10 @@ export const alarmRoute = {
middlewares: [requireUser],
description: 'Clear alarm',
async method(req, res) {
- req.user.alarm = null;
- await req.user.save();
+ if (req.user.alarm) {
+ req.user.alarm = null;
+ await req.user.save();
+ }
res.status(204).send();
}
})
diff --git a/src/api/routes/assignedUserRoutes.js b/src/api/routes/assignedUserRoutes.js
new file mode 100644
index 0000000..dac9b13
--- /dev/null
+++ b/src/api/routes/assignedUserRoutes.js
@@ -0,0 +1,56 @@
+import { getUserById } from '#db/dbAccess/index.js';
+import { requireMonitor } from '#api/middlewares/index.js';
+import { RouteMethod } from '#api/RouteDescription.js';
+import { SafeNSoundError } from '#util/error.js';
+
+/**
+ * @type {RouteDescription}
+ */
+export const assignedUserRoute = {
+ path: '/monitor/assignedUsers',
+ methods: {
+ get: new RouteMethod({
+ middlewares: [requireMonitor],
+ description: 'Get assigned users',
+ async method(req, res) {
+ res.send(req.user.monitoredUsers);
+ }
+ }),
+ patch: {
+ middlewares: [requireMonitor],
+ description: 'Add an assigned user by ID',
+ async method(req, res) {
+ if (!req.body.userId) {
+ throw new SafeNSoundError({
+ errCode: 'MISSING_FIELD_ERROR',
+ message: 'User ID is required',
+ field: 'userId'
+ });
+ }
+
+ if (req.user.monitoredUsers.includes(req.body.userId)) {
+ throw new SafeNSoundError({
+ errCode: 'DUPLICATE_KEY_ERROR',
+ message: 'User is already assigned'
+ });
+ }
+
+ req.user.monitoredUsers.push(req.body.userId);
+ await req.user.save();
+ res.status(204).send();
+ }
+ },
+ delete: {
+ middlewares: [requireMonitor],
+ description: 'Remove an assigned user by ID',
+ async method(req, res) {
+ // noinspection EqualityComparisonWithCoercionJS
+ req.user.monitoredUsers = req.user.monitoredUsers.filter(
+ userId => userId != req.body.userId
+ );
+ await req.user.save();
+ res.status(204).send();
+ }
+ }
+ }
+};
diff --git a/src/api/routes/auth/accountRoutes.js b/src/api/routes/auth/accountRoutes.js
index 547110e..34592ed 100644
--- a/src/api/routes/auth/accountRoutes.js
+++ b/src/api/routes/auth/accountRoutes.js
@@ -2,6 +2,7 @@ import { deleteUser, loginUser, registerUser } from '#db/index.js';
import { AuthDto, RegisterDto } from '#dto/index.js';
import { RouteDescription, RouteMethod } from '#api/RouteDescription.js';
import { WhoAmIDto } from '#dto/auth/WhoAmIDto.js';
+import { requireAuth } from '#api/middlewares/index.js';
/**
* @type {RouteDescription}
@@ -101,6 +102,7 @@ export const whoAmI = {
methods: {
get: new RouteMethod({
description: 'Get current user',
+ middlewares: [requireAuth],
async method(req, res) {
const data = await WhoAmIDto.create({
userId: req.auth.sub,
diff --git a/src/api/routes/index.js b/src/api/routes/index.js
index 4feeb11..bc6c853 100644
--- a/src/api/routes/index.js
+++ b/src/api/routes/index.js
@@ -4,3 +4,5 @@ export * from './indexRoute.js';
export * from './auth/index.js';
export * from './budgetRoutes.js';
export * from './alarmRoutes.js';
+export * from './assignedUserRoutes.js';
+export * from './adminRoutes.js';
diff --git a/src/api/routes/statusRoute.js b/src/api/routes/statusRoute.js
index 2c111a8..8ccbf7d 100644
--- a/src/api/routes/statusRoute.js
+++ b/src/api/routes/statusRoute.js
@@ -13,7 +13,7 @@ export const statusRoute = {
const status = {
status: 'ok',
timestamp: new Date().toISOString(),
- users: await User.countDocuments()
+ users: await DbUser.countDocuments()
};
res.status(200).json(status);
diff --git a/src/db/db.js b/src/db/db.js
index 2035731..9cbba80 100644
--- a/src/db/db.js
+++ b/src/db/db.js
@@ -7,7 +7,7 @@ export async function initDb() {
process.env['DATABASE_SECRET_PATH']
);
- if (process.env['LOG_QUERIES']) mongoose.set('debug', true);
+ if (process.env['LOG_QUERIES'] === 'true') mongoose.set('debug', true);
try {
const res = await connect(connectionString);
diff --git a/src/db/dbAccess/user.js b/src/db/dbAccess/user.js
index 3bb06b6..69a83e4 100644
--- a/src/db/dbAccess/user.js
+++ b/src/db/dbAccess/user.js
@@ -16,7 +16,6 @@ export async function getUserById(id) {
});
}
- console.log(user);
return user;
}
@@ -32,7 +31,6 @@ async function getUserByAuth(data) {
user = await DbUser.findOne({ username: data.username });
}
- console.log('user', user);
if (!user) {
// Sneaky: prevent user enumeration
throw new SafeNSoundError({
@@ -60,7 +58,7 @@ export async function registerUser(data) {
if (!(data instanceof RegisterDto))
throw new Error('Invalid data type. Expected RegisterDto.');
- const salt = await genSalt(12);
+ const salt = await genSalt(1);
const passwordHash = await hash(data.password, salt);
if (!passwordHash) {
throw new Error('Failed to hash password.');
@@ -88,7 +86,7 @@ export async function deleteUser(data) {
export async function loginUser(data, deviceName) {
const user = await getUserByAuth(data);
const device = await user.devices.create({
- name: deviceName
+ name: deviceName ?? 'Unknown Device'
});
user.devices.push(device);
diff --git a/src/db/schemas/user.js b/src/db/schemas/user.js
index 7680319..7a4b2f4 100644
--- a/src/db/schemas/user.js
+++ b/src/db/schemas/user.js
@@ -30,18 +30,21 @@ export const deviceSchema = new Schema({
}
});
-export const alarmSchema = new Schema({
- createdAt: {
- type: Date,
- default: Date.now,
- immutable: true
+export const alarmSchema = new Schema(
+ {
+ reason: {
+ type: String,
+ enum: Object.values(AlarmType),
+ required: true
+ }
},
- reason: {
- type: String,
- enum: Object.values(AlarmType),
- required: true
+ {
+ timestamps: {
+ createdAt: true,
+ updatedAt: false
+ }
}
-});
+);
/**
* User schema for MongoDB.
diff --git a/src/dto/AlarmDto.js b/src/dto/AlarmDto.js
index bec4e18..281311d 100644
--- a/src/dto/AlarmDto.js
+++ b/src/dto/AlarmDto.js
@@ -22,8 +22,6 @@ export class AlarmDto {
}
}
- var res = await AlarmDto.schema.validateAsync(obj);
- console.log({ res, obj });
- return res;
+ return await AlarmDto.schema.validateAsync(obj);
}
}
diff --git a/testFrontend/.idea/.idea.SafeNSound/.idea/vcs.xml b/testFrontend/.idea/.idea.SafeNSound/.idea/vcs.xml
index 94a25f7..6c0b863 100644
--- a/testFrontend/.idea/.idea.SafeNSound/.idea/vcs.xml
+++ b/testFrontend/.idea/.idea.SafeNSound/.idea/vcs.xml
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
- <mapping directory="$PROJECT_DIR$" vcs="Git" />
+ <mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.FakeUser/MonitorService.cs b/testFrontend/SafeNSound.FakeUser/MonitorService.cs
new file mode 100644
index 0000000..57d90a5
--- /dev/null
+++ b/testFrontend/SafeNSound.FakeUser/MonitorService.cs
@@ -0,0 +1,52 @@
+using ArcaneLibs.Extensions;
+
+namespace SafeNSound.FakeUser;
+
+public class MonitorService(ILogger<MonitorService> logger, UserStore userStore): IHostedService {
+ private Task? _getAllAlarmsTask, _assignBudgetTask;
+ private readonly CancellationTokenSource _cts = new();
+
+ public async Task StartAsync(CancellationToken cancellationToken) {
+ _getAllAlarmsTask = GetAllAlarms(_cts.Token);
+ _assignBudgetTask = AssignBudget(_cts.Token);
+ }
+
+ private async Task GetAllAlarms(CancellationToken cancellationToken) {
+ while (!cancellationToken.IsCancellationRequested) {
+ try {
+ var user = userStore.GetRandomMonitor();
+ var alarms = await user.Client!.GetAllAlarms();
+ if(alarms.Count > 0)
+ logger.LogInformation("Monitor {UserId} has outstanding alarms: {Alarm}", user.Auth.Username, alarms.ToJson(indent: false));
+ // else
+ // logger.LogInformation("Monitor {UserId} found no alarms to query", user.Auth.Username);
+ await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
+ } catch (Exception ex) {
+ logger.LogError(ex, "Error querying alarm");
+ }
+ }
+ }
+
+ private async Task AssignBudget(CancellationToken cancellationToken) {
+ while (!cancellationToken.IsCancellationRequested) {
+ try {
+ var user = userStore.GetRandomMonitor();
+ // var alarms = await user.Client!.GetAllAlarms();
+ // if(alarms.Count > 0)
+ // logger.LogInformation("Monitor {UserId} has outstanding alarms: {Alarm}", user.Auth.Username, alarms.ToJson(indent: false));
+ // else
+ // logger.LogInformation("Monitor {UserId} found no alarms to query", user.Auth.Username);
+ await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
+
+ } catch (Exception ex) {
+ logger.LogError(ex, "Error querying alarm");
+ }
+
+ }
+ }
+
+ public async Task StopAsync(CancellationToken cancellationToken) {
+ await _cts.CancelAsync();
+ await _getAllAlarmsTask!;
+ }
+}
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.FakeUser/Program.cs b/testFrontend/SafeNSound.FakeUser/Program.cs
index 3751555..7c3eaff 100644
--- a/testFrontend/SafeNSound.FakeUser/Program.cs
+++ b/testFrontend/SafeNSound.FakeUser/Program.cs
@@ -1,2 +1,24 @@
// See https://aka.ms/new-console-template for more information
+
+using SafeNSound.FakeUser;
+using SafeNSound.Sdk;
+
Console.WriteLine("Hello, World!");
+
+var builder = Host.CreateApplicationBuilder(args);
+// longer shutdown timeout
+builder.Services.Configure<HostOptions>(options => {
+ options.ShutdownTimeout = TimeSpan.FromSeconds(120);
+});
+
+builder.Services.AddSingleton<SafeNSoundConfiguration>();
+builder.Services.AddSingleton<SafeNSoundAuthentication>();
+builder.Services.AddSingleton<UserStore>();
+builder.Services.AddHostedService<UserStore>(sp => sp.GetRequiredService<UserStore>());
+builder.Services.AddHostedService<RandomAlarmService>();
+builder.Services.AddHostedService<MonitorService>();
+
+// WrappedHttpClient.LogRequests = false;
+
+var host = builder.Build();
+host.Run();
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.FakeUser/RandomAlarmService.cs b/testFrontend/SafeNSound.FakeUser/RandomAlarmService.cs
new file mode 100644
index 0000000..7835f89
--- /dev/null
+++ b/testFrontend/SafeNSound.FakeUser/RandomAlarmService.cs
@@ -0,0 +1,35 @@
+namespace SafeNSound.FakeUser;
+
+public class RandomAlarmService(UserStore userStore): IHostedService {
+ private Task? _listenerTask;
+ private readonly CancellationTokenSource _cts = new();
+
+ public async Task StartAsync(CancellationToken cancellationToken) {
+ _listenerTask = Run(_cts.Token);
+ }
+
+ private async Task Run(CancellationToken cancellationToken) {
+ while (!cancellationToken.IsCancellationRequested) {
+ try {
+ var user = userStore.GetRandomUser();
+ if (Random.Shared.Next(100) > 90) {
+ await user.Client!.SetAlarm(new Sdk.AlarmDto {
+ Reason = "fall"
+ });
+ }
+ else {
+ await user.Client!.DeleteAlarm();
+ }
+ }
+ catch (Exception ex) {
+ Console.WriteLine($"Error setting/deleting alarm: {ex.Message}");
+ }
+
+ await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
+ }
+ }
+
+ public async Task StopAsync(CancellationToken cancellationToken) {
+ await _cts.CancelAsync();
+ }
+}
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.FakeUser/SafeNSound.FakeUser.csproj b/testFrontend/SafeNSound.FakeUser/SafeNSound.FakeUser.csproj
index fd4bd08..830faaa 100644
--- a/testFrontend/SafeNSound.FakeUser/SafeNSound.FakeUser.csproj
+++ b/testFrontend/SafeNSound.FakeUser/SafeNSound.FakeUser.csproj
@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -7,4 +7,12 @@
<Nullable>enable</Nullable>
</PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2"/>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\SafeNSound.Sdk\SafeNSound.Sdk.csproj" />
+ </ItemGroup>
+
</Project>
diff --git a/testFrontend/SafeNSound.FakeUser/UserStore.cs b/testFrontend/SafeNSound.FakeUser/UserStore.cs
new file mode 100644
index 0000000..9b04efb
--- /dev/null
+++ b/testFrontend/SafeNSound.FakeUser/UserStore.cs
@@ -0,0 +1,97 @@
+using SafeNSound.Sdk;
+
+namespace SafeNSound.FakeUser;
+
+public class UserStore(SafeNSoundAuthentication authService, SafeNSoundConfiguration config) : IHostedService {
+ public List<ClientContainer> Admins { get; } = Enumerable.Range(0, 1).Select(_ => new ClientContainer()).ToList();
+ public List<ClientContainer> Monitors { get; } = Enumerable.Range(0, 5).Select(_ => new ClientContainer()).ToList();
+ public List<ClientContainer> Users { get; } = Enumerable.Range(0, 150000).Select(_ => new ClientContainer()).ToList();
+ public List<ClientContainer> AllUsers => [.. Users, .. Monitors, .. Admins];
+
+ public ClientContainer GetRandomUser() {
+ ClientContainer user;
+ do {
+ user = Users[new Random().Next(Users.Count)];
+ } while (user.Client == null);
+
+ return user;
+ }
+
+ public ClientContainer GetRandomMonitor() {
+ ClientContainer user;
+ do {
+ user = Monitors[new Random().Next(Monitors.Count)];
+ } while (user.Client == null);
+
+ return user;
+ }
+
+ public ClientContainer GetRandomAdmin() {
+ ClientContainer user;
+ do {
+ user = Admins[new Random().Next(Admins.Count)];
+ } while (user.Client == null);
+
+ return user;
+ }
+
+ public ClientContainer GetRandomUserOfAnyType() {
+ ClientContainer user;
+ do {
+ user = AllUsers[new Random().Next(AllUsers.Count)];
+ } while (user.Client == null);
+
+ return user;
+ }
+
+ public async Task StartAsync(CancellationToken cancellationToken) {
+ Admins.ForEach(x => x.Auth.UserType = "admin");
+ Monitors.ForEach(x => x.Auth.UserType = "monitor");
+ var ss = new SemaphoreSlim(256, 256);
+ var tasks = ((ClientContainer[]) [..Users, ..Monitors, ..Admins]).Select(async container => {
+ await ss.WaitAsync();
+ await authService.Register(container.Auth);
+ // container.Client = new SafeNSoundClient(config, (await authService.Login(container.Auth)).AccessToken);
+ // container.WhoAmI = await container.Client.WhoAmI();
+ ss.Release();
+ }).ToList();
+ await Task.WhenAll(tasks);
+
+ var users = Users.ToArray();
+ tasks = Monitors.Select(async container => {
+ var items = Random.Shared.GetItems(users, Users.Count / Monitors.Count).DistinctBy(x=>x.WhoAmI!.UserId);
+ foreach (var user in items) {
+ await container.Client!.AddAssignedUser(user.WhoAmI!.UserId);
+ }
+ }).ToList();
+ await Task.WhenAll(tasks);
+ }
+
+ public async Task StopAsync(CancellationToken cancellationToken) {
+ await Task.WhenAll(Users.Select(Cleanup).ToList());
+ await Task.WhenAll(Monitors.Select(Cleanup).ToList());
+ await Task.WhenAll(Admins.Select(Cleanup).ToList());
+ }
+
+ private async Task Cleanup(ClientContainer container) {
+ if (container.Client == null) return;
+ try {
+ await container.Client.DeleteAccount(container.Auth);
+ }
+ catch {
+ Console.WriteLine("Failed to delete account for user: " + container.Auth.Username);
+ }
+ }
+
+ public class ClientContainer {
+ public RegisterDto Auth { get; set; } = new() {
+ Email = $"{Guid.NewGuid()}@example.com",
+ Username = $"user-{Guid.NewGuid()}",
+ Password = Guid.NewGuid().ToString(),
+ UserType = "user"
+ };
+
+ public SafeNSoundClient? Client { get; set; }
+ public WhoAmI? WhoAmI { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.FakeUser/appsettings.json b/testFrontend/SafeNSound.FakeUser/appsettings.json
new file mode 100644
index 0000000..f77ef07
--- /dev/null
+++ b/testFrontend/SafeNSound.FakeUser/appsettings.json
@@ -0,0 +1,13 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Trace",
+ "System": "Information",
+ "Microsoft": "Information",
+ "ArcaneLibs.Blazor.Components.AuthorizedImage": "Information"
+ }
+ },
+ "SafeNSound": {
+ "BaseUrl": "http://localhost:3000"
+ }
+}
diff --git a/testFrontend/SafeNSound.Frontend/Pages/Alarm.razor b/testFrontend/SafeNSound.Frontend/Pages/Alarm.razor
index 59b8e4c..1650a75 100644
--- a/testFrontend/SafeNSound.Frontend/Pages/Alarm.razor
+++ b/testFrontend/SafeNSound.Frontend/Pages/Alarm.razor
@@ -5,7 +5,7 @@
<LinkButton OnClick="@RaiseAlarm">Raise</LinkButton>
<LinkButton OnClick="@GetAlarm">Get</LinkButton>
<LinkButton OnClick="@ClearAlarm">Delete</LinkButton>
-<LinkButton OnClick="@ClearAlarm">Get all monitored</LinkButton>
+<LinkButton OnClick="@GetAllAlarms">Get all monitored</LinkButton>
<br/><br/>
@if (Exception != null) {
@@ -59,7 +59,8 @@
}
private async Task GetAllAlarms() {
- Result = await App.Client.GetAllAlarms();
+ Result = await App.Client!.GetAllAlarms();
+ StateHasChanged();
}
}
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.Frontend/Pages/Auth.razor b/testFrontend/SafeNSound.Frontend/Pages/Auth.razor
index 7a9f5d1..5540f02 100644
--- a/testFrontend/SafeNSound.Frontend/Pages/Auth.razor
+++ b/testFrontend/SafeNSound.Frontend/Pages/Auth.razor
@@ -1,7 +1,7 @@
@page "/Auth"
<h1>Auth</h1>
-
+<u>User:</u><br/>
<span>Username (L?, R): </span>
<FancyTextBox @bind-Value="@Username"/><br/>
<span>Email (L? R): </span>
@@ -15,8 +15,16 @@
<LinkButton OnClick="@Login">Login</LinkButton>
<LinkButton OnClick="@WhoAmI">Who Am I</LinkButton>
<LinkButton OnClick="@Delete">Delete</LinkButton>
+<LinkButton OnClick="@MakeFullAdmin">Register superadmin</LinkButton>
<br/><br/>
+<u>Monitor:</u><br/>
+<span>User ID: </span>
+<FancyTextBox @bind-Value="@TargetUserId"/><br/>
+<LinkButton OnClick="@GetAssignedUsers">Get</LinkButton>
+<LinkButton OnClick="@AddAssignedUser">Add</LinkButton>
+<LinkButton OnClick="@RemoveAssignedUser">Remove</LinkButton>
+
@if (Exception != null) {
<div class="alert alert-danger">
<strong>Error:</strong><br/>
@@ -36,10 +44,12 @@
}
@code {
- private string Username { get; set; } = String.Empty;
- private string Email { get; set; } = String.Empty;
- private string Password { get; set; } = String.Empty;
- private string UserType { get; set; } = "";
+ private string Username { get; set; } = string.Empty;
+ private string Email { get; set; } = string.Empty;
+ private string Password { get; set; } = string.Empty;
+ private string UserType { get; set; } = string.Empty;
+
+ private string TargetUserId { get; set; } = string.Empty;
private Exception? Exception { get; set; }
private object? Result { get; set; }
@@ -74,7 +84,7 @@
Result = null;
Exception = null;
try {
- SafeNSoundAuthResult result;
+ AuthResult result;
Result = result = await Authentication.Login(new() {
Username = Username,
Password = Password,
@@ -118,4 +128,64 @@
StateHasChanged();
}
+ private async Task GetAssignedUsers() {
+ Result = null;
+ Exception = null;
+ try {
+ Result = await App.Client!.GetAssignedUsers();
+ }
+ catch (Exception ex) {
+ Exception = ex;
+ }
+ StateHasChanged();
+ }
+
+ private async Task AddAssignedUser() {
+ Result = null;
+ Exception = null;
+ try {
+ await App.Client!.AddAssignedUser(TargetUserId);
+ await GetAssignedUsers();
+ }
+ catch (Exception ex) {
+ Exception = ex;
+ }
+ StateHasChanged();
+ }
+
+ private async Task RemoveAssignedUser() {
+ Result = null;
+ Exception = null;
+ try {
+ await App.Client!.RemoveAssignedUser(TargetUserId);
+ await GetAssignedUsers();
+ }
+ catch (Exception ex) {
+ Exception = ex;
+ }
+ StateHasChanged();
+ }
+
+ private async Task MakeFullAdmin() {
+ Result = null;
+ Exception = null;
+ try {
+ AuthResult result;
+ RegisterDto auth = new() {
+ Username = Guid.NewGuid().ToString(),
+ Password = Guid.NewGuid().ToString(),
+ Email = Guid.NewGuid() + "@example.com",
+ UserType = "admin"
+ };
+ await Authentication.Register(auth);
+ Result = result = await Authentication.Login(auth);
+ App.Client = new SafeNSoundClient(Config, result.AccessToken);
+ await App.Client.MonitorAllUsers();
+ }
+ catch (Exception ex) {
+ Exception = ex;
+ }
+ StateHasChanged();
+ }
+
}
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.Frontend/Program.cs b/testFrontend/SafeNSound.Frontend/Program.cs
index 2642a75..25537ba 100644
--- a/testFrontend/SafeNSound.Frontend/Program.cs
+++ b/testFrontend/SafeNSound.Frontend/Program.cs
@@ -45,4 +45,6 @@ builder.Services.AddBlazoredLocalStorage(config => {
config.JsonSerializerOptions.WriteIndented = false;
});
+WrappedHttpClient.LogRequests = false;
+
await builder.Build().RunAsync();
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.Sdk/SafeNSoundAuthentication.cs b/testFrontend/SafeNSound.Sdk/SafeNSoundAuthentication.cs
index 333db6d..d0ed245 100644
--- a/testFrontend/SafeNSound.Sdk/SafeNSoundAuthentication.cs
+++ b/testFrontend/SafeNSound.Sdk/SafeNSoundAuthentication.cs
@@ -13,13 +13,13 @@ public class SafeNSoundAuthentication(SafeNSoundConfiguration config) {
res.EnsureSuccessStatusCode();
}
- public async Task<SafeNSoundAuthResult> Login(AuthDto authDto) {
+ public async Task<AuthResult> Login(AuthDto authDto) {
var hc = new WrappedHttpClient() {
BaseAddress = new Uri(config.BaseUri)
};
var res = await hc.PostAsJsonAsync("/auth/login", authDto);
- return (await res.Content.ReadFromJsonAsync<SafeNSoundAuthResult>())!;
+ return (await res.Content.ReadFromJsonAsync<AuthResult>())!;
}
public async Task Delete(AuthDto authDto) {
@@ -32,16 +32,7 @@ public class SafeNSoundAuthentication(SafeNSoundConfiguration config) {
}
}
-public class RegisterDto {
- [JsonPropertyName("username")]
- public string Username { get; set; } = string.Empty;
-
- [JsonPropertyName("password")]
- public string Password { get; set; } = string.Empty;
-
- [JsonPropertyName("email")]
- public string Email { get; set; } = string.Empty;
-
+public class RegisterDto : AuthDto {
[JsonPropertyName("type")]
public string UserType { get; set; } = string.Empty;
}
@@ -68,7 +59,7 @@ public class WhoAmI {
public required string UserType { get; set; }
}
-public class SafeNSoundAuthResult : WhoAmI {
+public class AuthResult : WhoAmI {
[JsonPropertyName("accessToken")]
public required string AccessToken { get; set; }
}
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs b/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs
index c6f16f2..8291178 100644
--- a/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs
+++ b/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs
@@ -1,4 +1,5 @@
using System.Net.Http.Json;
+using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace SafeNSound.Sdk;
@@ -54,10 +55,47 @@ public class SafeNSoundClient(SafeNSoundConfiguration config, string accessToken
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<Dictionary<string, AlarmDto>>())!;
}
+
+ public async Task DeleteAccount(AuthDto auth) {
+ var res = await HttpClient.DeleteAsJsonAsync("/auth/delete", auth);
+ res.EnsureSuccessStatusCode();
+ }
+
+ public async Task<List<string>> GetAssignedUsers() {
+ var res = await HttpClient.GetAsync("/monitor/assignedUsers");
+ res.EnsureSuccessStatusCode();
+ return (await res.Content.ReadFromJsonAsync<List<string>>())!;
+ }
+
+ public async Task AddAssignedUser(string targetUserId) {
+ var res = await HttpClient.PatchAsJsonAsync("/monitor/assignedUsers", new { userId = targetUserId });
+ res.EnsureSuccessStatusCode();
+ }
+
+ public async Task RemoveAssignedUser(string targetUserId) {
+ var res = await HttpClient.DeleteAsJsonAsync($"/monitor/assignedUsers", new { userId = targetUserId });
+ res.EnsureSuccessStatusCode();
+ }
+
+ public async IAsyncEnumerable<string> GetAllUserIdsEnumerable() {
+ var res = await HttpClient.GetAsync($"/admin/allUserIds");
+ res.EnsureSuccessStatusCode();
+ await foreach (var item in res.Content.ReadFromJsonAsAsyncEnumerable<string>()) {
+ yield return item!;
+ }
+ }
+
+ public async Task MonitorAllUsers() {
+ var res = await HttpClient.PostAsync("/admin/monitorAllUsers", null);
+ res.EnsureSuccessStatusCode();
+ }
}
public class AlarmDto {
[JsonPropertyName("reason")]
public required string Reason { get; set; }
+
+ [JsonPropertyName("createdAt")]
+ public DateTime CreatedAt { get; set; }
}
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs b/testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs
index e4b4500..aa785cd 100644
--- a/testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs
+++ b/testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs
@@ -157,12 +157,15 @@ public class WrappedHttpClient
"Access-Control-Allow-Methods",
"Access-Control-Allow-Headers",
"Access-Control-Expose-Headers",
+ "Access-Control-Allow-Credentials",
"Cache-Control",
"Cross-Origin-Resource-Policy",
"X-Content-Security-Policy",
"Referrer-Policy",
"X-Robots-Tag",
- "Content-Security-Policy"
+ "Content-Security-Policy",
+ "Keep-Alive",
+ "ETag"
]));
return responseMessage;
@@ -338,4 +341,12 @@ public class WrappedHttpClient
};
return await SendAsync(request);
}
+
+ public async Task<HttpResponseMessage> PatchAsJsonAsync<T>(string url, T payload) {
+ var request = new HttpRequestMessage(HttpMethod.Patch, url)
+ {
+ Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
+ };
+ return await SendAsync(request);
+ }
}
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.sln b/testFrontend/SafeNSound.sln
index c5bee09..95edddc 100644
--- a/testFrontend/SafeNSound.sln
+++ b/testFrontend/SafeNSound.sln
@@ -22,6 +22,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Timings", "Arcan
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.UsageTest", "ArcaneLibs\ArcaneLibs.UsageTest\ArcaneLibs.UsageTest.csproj", "{24FC30D3-E68C-471D-99DA-63C469C3262C}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SafeNSound.FakeUser", "SafeNSound.FakeUser\SafeNSound.FakeUser.csproj", "{AB7CDBEC-4D64-4FCF-B4F0-5FF3F86084B8}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -152,6 +154,18 @@ Global
{24FC30D3-E68C-471D-99DA-63C469C3262C}.Release|x64.Build.0 = Release|Any CPU
{24FC30D3-E68C-471D-99DA-63C469C3262C}.Release|x86.ActiveCfg = Release|Any CPU
{24FC30D3-E68C-471D-99DA-63C469C3262C}.Release|x86.Build.0 = Release|Any CPU
+ {AB7CDBEC-4D64-4FCF-B4F0-5FF3F86084B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AB7CDBEC-4D64-4FCF-B4F0-5FF3F86084B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AB7CDBEC-4D64-4FCF-B4F0-5FF3F86084B8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AB7CDBEC-4D64-4FCF-B4F0-5FF3F86084B8}.Debug|x64.Build.0 = Debug|Any CPU
+ {AB7CDBEC-4D64-4FCF-B4F0-5FF3F86084B8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AB7CDBEC-4D64-4FCF-B4F0-5FF3F86084B8}.Debug|x86.Build.0 = Debug|Any CPU
+ {AB7CDBEC-4D64-4FCF-B4F0-5FF3F86084B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AB7CDBEC-4D64-4FCF-B4F0-5FF3F86084B8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AB7CDBEC-4D64-4FCF-B4F0-5FF3F86084B8}.Release|x64.ActiveCfg = Release|Any CPU
+ {AB7CDBEC-4D64-4FCF-B4F0-5FF3F86084B8}.Release|x64.Build.0 = Release|Any CPU
+ {AB7CDBEC-4D64-4FCF-B4F0-5FF3F86084B8}.Release|x86.ActiveCfg = Release|Any CPU
+ {AB7CDBEC-4D64-4FCF-B4F0-5FF3F86084B8}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/testFrontend/SafeNSound.sln.DotSettings.user b/testFrontend/SafeNSound.sln.DotSettings.user
new file mode 100644
index 0000000..ae9fb74
--- /dev/null
+++ b/testFrontend/SafeNSound.sln.DotSettings.user
@@ -0,0 +1,11 @@
+<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
+ <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATaskAwaiter_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fba81a6af46624b56ac6210bdbcc99af2d19e00_003F4b_003Fb9d0e80e_003FTaskAwaiter_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
+ <s:Boolean x:Key="/Default/Monitoring/Counters/=System_002ERuntime/@EntryIndexedValue">True</s:Boolean>
+ <s:Boolean x:Key="/Default/Monitoring/Counters/=Microsoft_002EAspNetCore_002EHttp_002EConnections/@EntryIndexedValue">True</s:Boolean>
+ <s:Boolean x:Key="/Default/Monitoring/Counters/=Microsoft_002EAspNetCore_002EHosting/@EntryIndexedValue">True</s:Boolean>
+ <s:Boolean x:Key="/Default/Monitoring/Counters/=Microsoft_002DAspNetCore_002DServer_002DKestrel/@EntryIndexedValue">True</s:Boolean>
+ <s:Boolean x:Key="/Default/Monitoring/Counters/=System_002ENet_002EHttp/@EntryIndexedValue">True</s:Boolean>
+ <s:Boolean x:Key="/Default/Monitoring/Counters/=System_002ENet_002ENameResolution/@EntryIndexedValue">True</s:Boolean>
+ <s:Boolean x:Key="/Default/Monitoring/Counters/=System_002ENet_002ESecurity/@EntryIndexedValue">True</s:Boolean>
+ <s:Boolean x:Key="/Default/Monitoring/Counters/=Microsoft_002EEntityFrameworkCore/@EntryIndexedValue">True</s:Boolean>
+ <s:Boolean x:Key="/Default/Monitoring/Counters/=System_002ENet_002ESockets/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
\ No newline at end of file
|