summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.idea/dataSources.xml2
-rw-r--r--.idea/nodejs-final-assignment.iml1
-rw-r--r--DEPLOY.md51
-rw-r--r--README.md2
-rw-r--r--doc/systemd.service30
-rw-r--r--endpoints.http117
-rw-r--r--flake.nix134
-rw-r--r--plan.md7
-rw-r--r--src/api/routes.js1
-rw-r--r--src/api/routes/adminRoutes.js12
-rw-r--r--src/api/routes/alarmRoutes.js40
-rw-r--r--src/api/routes/assignedUserRoutes.js12
-rw-r--r--src/api/routes/auth/accountRoutes.js14
-rw-r--r--src/api/routes/auth/adminAccountRoutes.js58
-rw-r--r--src/api/routes/auth/deviceRoutes.js99
-rw-r--r--src/api/routes/auth/index.js1
-rw-r--r--src/api/routes/budgetRoutes.js110
-rw-r--r--src/db/schemas/sensorHistory.js9
-rw-r--r--src/db/schemas/spendHistory.js15
-rw-r--r--src/db/schemas/user.js152
-rw-r--r--src/dto/auth/RegisterDto.js11
-rw-r--r--testFrontend/SafeNSound.FakeUser/MonitorService.cs102
-rw-r--r--testFrontend/SafeNSound.FakeUser/Program.cs2
-rw-r--r--testFrontend/SafeNSound.FakeUser/RandomAlarmService.cs35
-rw-r--r--testFrontend/SafeNSound.FakeUser/UserService.cs59
-rw-r--r--testFrontend/SafeNSound.FakeUser/UserStore.cs18
-rw-r--r--testFrontend/SafeNSound.Frontend/Pages/Auth.razor92
-rw-r--r--testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs132
-rw-r--r--testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs4
-rw-r--r--testFrontend/SafeNSound.sln.DotSettings.user3
30 files changed, 945 insertions, 380 deletions
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml

index f495692..4dc945a 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml
@@ -5,7 +5,7 @@ <driver-ref>mongo.4</driver-ref> <synchronize>true</synchronize> <jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver> - <jdbc-url>mongodb://localhost:27017/nodejs</jdbc-url> + <jdbc-url>mongodb://localhost:27017/nodejs?authSource=admin</jdbc-url> <working-dir>$ProjectFileDir$</working-dir> <driver-properties> <property name="authSource" value="admin" /> diff --git a/.idea/nodejs-final-assignment.iml b/.idea/nodejs-final-assignment.iml
index 24643cc..d1e5227 100644 --- a/.idea/nodejs-final-assignment.iml +++ b/.idea/nodejs-final-assignment.iml
@@ -5,6 +5,7 @@ <excludeFolder url="file://$MODULE_DIR$/.tmp" /> <excludeFolder url="file://$MODULE_DIR$/temp" /> <excludeFolder url="file://$MODULE_DIR$/tmp" /> + <excludeFolder url="file://$MODULE_DIR$/result" /> </content> <orderEntry type="inheritedJdk" /> <orderEntry type="sourceFolder" forTests="false" /> diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644
index 0000000..a427ed7 --- /dev/null +++ b/DEPLOY.md
@@ -0,0 +1,51 @@ +# Deployment +--- + +## NixOS (recommended) + +flake.nix: (production: [inputs](https://cgit.rory.gay/Rory-Open-Architecture.git/tree/flake.nix?h=d94f5#n104), [modules](https://cgit.rory.gay/Rory-Open-Architecture.git/tree/flake.nix?h=d94f5#n147), [service](https://cgit.rory.gay/Rory-Open-Architecture.git/tree/host/Rory-ovh/services/safensound.nix)) +```nix +# inputs section - using public mirror for unauthenticated access +inputs.safeNSound.url = "git+https://cgit.rory.gay/school/nodejs-final-assignment.git/"; + +# system configuration section +# modules = [ +safeNSound.modules.default + +# configuration section +# Make sure mongodb is set up: services.mongodb = { enable = true; ... }; +services.safeNSound = { + enable = true; + package = safeNSound.packages.default; + port = 3000; # or any other port you prefer + logRequests = "-"; # or "true" to log requests + databaseSecretPath = "/path/to/mongodb/connection/string"; + jwtSecretPath = "/path/to/jwt/secret/certificate"; + logQueries = false; # set to true to enable mongoose debug logs + logAuth = false; # set to true to enable authentication debug logs +}; +``` + +## Other Linux distros: + +### Preparation + +Make sure you have the following installed: +- Node.js (v22 or later) +- MongoDB + +```shell +git clone --recursive https://cgit.rory.gay/school/nodejs-final-assignment.git/ +cd nodejs-final-assignment +npm i +``` + +### Installation + +Please see your service manager: +- systemd (Debian, Arch, Fedora, ...): /etc/systemd/system/SafeNSound.service (see [example](https://cgit.rory.gay/school/nodejs-final-assignment.git/tree/doc/systemd.service)) +- Other: please refer to your service manager's documentation. + +## General setup considerations: +- Do not run as root! +- Secrets live on disk and should be handled via whatever secret scheme your system allows. (NixOS uses systemd by default). \ No newline at end of file diff --git a/README.md b/README.md
index fa2e942..bca91cb 100644 --- a/README.md +++ b/README.md
@@ -13,8 +13,8 @@ Environment variables: | Name | Default | Description | | ---- | ------- | ----------- | | `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. | | `JWT_SECRET_PATH` | ` ` | The path to the JWT secret certificate. | +| `LOG_REQUESTS` | `-` | Requests to log to the console by status, `-` to invert. | | `LOG_QUERIES` | `false` | Whether to enable mongoose debug logs | | `LOG_AUTH` | `false` | Whether to enable authentication debug logs | diff --git a/doc/systemd.service b/doc/systemd.service new file mode 100644
index 0000000..37a98ca --- /dev/null +++ b/doc/systemd.service
@@ -0,0 +1,30 @@ +[Unit] +After=network.target mongodb.service +Description=SafeNSound Service +Requires=mongodb.service + +[Service] +Environment="DATABASE_SECRET_PATH=/run/credentials/safensound.service/mongodb" +Environment="JWT_SECRET_PATH=/var/lib/SafeNSound" +Environment="LOG_AUTH=true" +Environment="LOG_QUERIES=true" +Environment="LOG_REQUESTS=-" +Environment="PORT=7645" +DynamicUser=true +ExecStart=/usr/bin/env node /opt/SafeNSound +LoadCredential=mongodb:/data/secrets/safensound-mongodb +NoNewPrivileges=true +PrivateDevices=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +Restart=always +StartLimitBurst=600 +StartLimitIntervalSec=60 +StateDirectory=SafeNSound +StateDirectoryMode=0700 +Type=simple +WorkingDirectory=/var/lib/SafeNSound + +[Install] +WantedBy=multi-user.target diff --git a/endpoints.http b/endpoints.http
index aab693a..ce4c2d3 100644 --- a/endpoints.http +++ b/endpoints.http
@@ -3,40 +3,74 @@ @password=admin @email=admin@example.com @userType=admin +@accessToken=someValueXyzXyz -GET {{baseUrl}}/budget/:id/add HTTP/1.1 - -### -# Get all users (raw) -GET {{baseUrl}}/admin/users HTTP/1.1 +# Get all user IDs +GET {{baseUrl}}/admin/allUserIds HTTP/1.1 Authorization: Bearer {{accessToken}} ### -# Get a user (raw) -GET {{baseUrl}}/admin/user/:id HTTP/1.1 +# Monitor all users +POST {{baseUrl}}/admin/monitorAllUsers HTTP/1.1 Authorization: Bearer {{accessToken}} ### -DELETE {{baseUrl}}/admin/user/:id HTTP/1.1 - -### -GET {{baseUrl}}/alarm/:id HTTP/1.1 +# Get the alarm for a monitored user, if one is set +GET {{baseUrl}}/user/:id/alarm HTTP/1.1 +Authorization: Bearer {{accessToken}} ### -PUT {{baseUrl}}/alarm/:id HTTP/1.1 +# Clear the alarm for a monitored user +DELETE {{baseUrl}}/user/:id/alarm HTTP/1.1 +Authorization: Bearer {{accessToken}} ### +# Get a list of all alarms for monitored users GET {{baseUrl}}/alarms HTTP/1.1 +Authorization: Bearer {{accessToken}} ### +# Get the current user's alarm GET {{baseUrl}}/alarm/@me HTTP/1.1 +Authorization: Bearer {{accessToken}} ### +# Raise an alarm (enum: one of "fall" or "toilet") PUT {{baseUrl}}/alarm/@me HTTP/1.1 +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "reason": "fall" +} + ### +# Clear alarm DELETE {{baseUrl}}/alarm/@me HTTP/1.1 +Authorization: Bearer {{accessToken}} + +### +# Get assigned users +GET {{baseUrl}}/monitor/assignedUsers HTTP/1.1 +Authorization: Bearer {{accessToken}} + +### +# Add an assigned user by ID +PATCH {{baseUrl}}/monitor/assignedUsers HTTP/1.1 +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "userId": "abcdef" +} + + +### +# Remove an assigned user by ID +DELETE {{baseUrl}}/monitor/assignedUsers HTTP/1.1 +Authorization: Bearer {{accessToken}} ### # Delete account @@ -51,13 +85,27 @@ Content-Type: application/json ### -GET {{baseUrl}}/budget/:id HTTP/1.1 +# Get the budget for a monitored user +GET {{baseUrl}}/user/:id/budget HTTP/1.1 +Authorization: Bearer {{accessToken}} ### -GET {{baseUrl}}/budget/@me HTTP/1.1 +# Add budget for a monitored user +PATCH {{baseUrl}}/user/:id/budget HTTP/1.1 +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "venue": "Monitor 123", + "reason": "Just short for a coke to deal with diabetes", + "amount": 0.15 +} + ### +# Get all devices registered to the user GET {{baseUrl}}/auth/devices HTTP/1.1 +Authorization: Bearer {{accessToken}} ### # Get the index page (empty) @@ -76,11 +124,32 @@ Content-Type: application/json ### -# Log out from a device (TODO) +# Log out from a device POST {{baseUrl}}/auth/logout HTTP/1.1 Authorization: Bearer {{accessToken}} ### +# Get user device by ID +GET {{baseUrl}}/auth/devices/:id HTTP/1.1 +Authorization: Bearer {{accessToken}} + +### +# Delete user device by ID +DELETE {{baseUrl}}/auth/devices/:id HTTP/1.1 +Authorization: Bearer {{accessToken}} + +### +# Update user device by ID +PATCH {{baseUrl}}/auth/devices/:id HTTP/1.1 +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "name": "New Device Name" +} + + +### # Create a new user POST {{baseUrl}}/auth/register HTTP/1.1 Content-Type: application/json @@ -98,7 +167,25 @@ Content-Type: application/json GET {{baseUrl}}/status HTTP/1.1 ### +GET {{baseUrl}}/budget/@me HTTP/1.1 +Authorization: Bearer {{accessToken}} + +### +# Spend part of budget +PATCH {{baseUrl}}/budget/@me HTTP/1.1 +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "venue": "The Store", + "reason": "Bought a coke", + "amount": 0.85 +} + + +### # Get current user GET {{baseUrl}}/auth/whoami HTTP/1.1 +Authorization: Bearer {{accessToken}} ### diff --git a/flake.nix b/flake.nix
index 211b64b..16db909 100644 --- a/flake.nix +++ b/flake.nix
@@ -80,64 +80,96 @@ devShell = pkgs.mkShell { buildInputs = with pkgs; [ mongodb-compass + webstorm nodejs nodePackages.prettier ]; }; } - )) // { - nixosModules.default = { pkgs, config, lib, ...}: { - options.services.safensound = { - enable = lib.mkEnableOption "Enable SafeNSound service"; - package = lib.mkOption { - type = lib.types.package; - default = self.packages.${pkgs.stdenv.hostPlatform.system}.default; - description = "The SafeNSound service package to run."; - }; - port = lib.mkOption { - type = lib.types.port; - default = 3000; - description = "The port on which the SafeNSound service will listen."; - }; - dbCredentialsPath = lib.mkOption { - type = lib.types.path; - description = "Path to the database credentials file."; - }; - jwtSecretPath = lib.mkOption { - type = lib.types.path; - description = "Path to the JWT secret directory."; - }; - logRequests = lib.mkEnableOption "Log requests"; - logQueries = lib.mkEnableOption "Log queries"; - logAuth = lib.mkEnableOption "Log authentication"; - }; - - config = lib.mkIf (config.services.safensound.enable) ( - let - cfg = config.services.safensound; - in + )) + // { + nixosModules.default = + { + pkgs, + config, + lib, + ... + }: { - systemd.services.safensound = { - description = "SafeNSound Service"; - wantedBy = [ "multi-user.target" ]; - after = [ "network.target" "mongodb.service" ]; - requires = [ "mongodb.service" ]; - environment = { - PORT = cfg.port; - LOG_REQUESTS = cfg.logRequests; - DATABASE_SECRET_PATH = cfg.dbCredentialsPath; - JWT_SECRET_PATH = cfg.jwtSecretPath; - LOG_QUERIES = cfg.logQueries; - LOG_AUTH = cfg.logAuth; + options.services.safensound = { + enable = lib.mkEnableOption "Enable SafeNSound service"; + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.default; + description = "The SafeNSound service package to run."; }; - serviceConfig = { - Type = "simple"; - ExecStart = "${cfg.package}/bin/start"; - Restart = "on-failure"; - DynamicUser = true; + port = lib.mkOption { + type = lib.types.port; + default = 3000; + description = "The port on which the SafeNSound service will listen."; }; + dbCredentialsPath = lib.mkOption { + type = lib.types.path; + description = "Path to the database credentials file."; + }; + jwtSecretPath = lib.mkOption { + type = lib.types.path; + default = "/var/lib/SafeNSound"; + description = "Path to the JWT secret directory."; + }; + logRequests = lib.mkOption { + type = lib.types.string; + description = "Which requests to log."; + default = "-"; + }; + logQueries = lib.mkEnableOption "Log queries"; + logAuth = lib.mkEnableOption "Log authentication"; }; - }); - }; + + config = lib.mkIf (config.services.safensound.enable) ( + let + cfg = config.services.safensound; + in + { + systemd.services.safensound = { + description = "SafeNSound Service"; + wantedBy = [ "multi-user.target" ]; + after = [ + "network.target" + "mongodb.service" + ]; + requires = [ "mongodb.service" ]; + environment = { + PORT = toString cfg.port; + DATABASE_SECRET_PATH = "/run/credentials/safensound.service/mongodb"; + JWT_SECRET_PATH = cfg.jwtSecretPath; + LOG_AUTH = lib.boolToString cfg.logAuth; + LOG_QUERIES = lib.boolToString cfg.logQueries; + LOG_REQUESTS = cfg.logRequests; + }; + serviceConfig = { + Type = "simple"; + ExecStart = "${cfg.package}/bin/start"; + WorkingDirectory = "/var/lib/SafeNSound"; + StateDirectory = "SafeNSound"; + StateDirectoryMode = "0700"; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + NoNewPrivileges = true; + PrivateDevices = true; + DynamicUser = true; + Restart = "always"; + StartLimitIntervalSec = 60; + StartLimitBurst = 5; + + LoadCredential = [ + "mongodb:${cfg.dbCredentialsPath}" + ]; + }; + }; + } + ); + }; }; -} \ No newline at end of file +} diff --git a/plan.md b/plan.md
index 3588bf8..9d73806 100644 --- a/plan.md +++ b/plan.md
@@ -7,7 +7,7 @@ - [x] MongoDB CRUD operations and Mongoose modeling (with Joi validation) - [ ] Complete data and input validation (including ObjectId validation) - [x] Middleware functions -- [ ] A detailed deployment step-by-step plan: the application must be deployed on a cloud server and publicly accessible +- [x] A detailed deployment step-by-step plan: the application must be deployed on a cloud server and publicly accessible - [x] REST Client API calls provided for all API endpoints - [x] Comprehensive API documentation @@ -17,14 +17,15 @@ - [x] Registration - [ ] Validation based on type - [x] Login - - [-] Logout + - [x] Logout - [x] Delete - [ ] Password reset - [ ] User profile management - - [ ] Device management + - [x] Device management - [ ] Organisation (who's coming? announcement of events, ...) - [ ] Budgeting with tracking - [x] Get current budget + - [x] Add money (monitor) - [ ] Review spending history - [ ] Request additional budget in case of emergency - [x] Emergency alarm diff --git a/src/api/routes.js b/src/api/routes.js
index 279b1ef..e2765a3 100644 --- a/src/api/routes.js +++ b/src/api/routes.js
@@ -15,6 +15,7 @@ function logHttpHeader() { logPrefixed('@password=admin'); logPrefixed('@email=admin@example.com'); logPrefixed('@userType=admin'); + logPrefixed('@accessToken=someValueXyzXyz'); logPrefixed(''); logPrefixed(''); } diff --git a/src/api/routes/adminRoutes.js b/src/api/routes/adminRoutes.js
index 7a1d1e2..4baa5e4 100644 --- a/src/api/routes/adminRoutes.js +++ b/src/api/routes/adminRoutes.js
@@ -16,6 +16,9 @@ export const adminGetUserIdsRoute = { path: '/admin/allUserIds', methods: { get: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireAdmin], description: 'Get all user IDs', async method(req, res) { @@ -23,9 +26,13 @@ export const adminGetUserIdsRoute = { res.status(200); res.write('[\n'); + let first = true; const users = DbUser.find().lean().cursor(); for await (const user of users) { - res.write(JSON.stringify(user._id) + ',\n'); + res.write( + (first ? '' : ',') + JSON.stringify(user._id) + '\n' + ); + first = false; } res.write(']'); @@ -38,6 +45,9 @@ export const adminMonitorAllRoute = { path: '/admin/monitorAllUsers', methods: { post: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireAdmin], description: 'Monitor all users', async method(req, res) { diff --git a/src/api/routes/alarmRoutes.js b/src/api/routes/alarmRoutes.js
index aeae4ab..b0147ab 100644 --- a/src/api/routes/alarmRoutes.js +++ b/src/api/routes/alarmRoutes.js
@@ -15,14 +15,20 @@ export const alarmByUserRoute = { path: '/user/:id/alarm', methods: { get: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireMonitor], description: 'Get the alarm for a monitored user, if one is set', async method(req, res) { const user = await getUserById(req.params.id); - res.send(user.alarm); + res.send(user.alarm ?? 'null'); } }), delete: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireMonitor], description: 'Clear the alarm for a monitored user', async method(req, res) { @@ -44,23 +50,27 @@ export const alarmListRoute = { path: '/alarms', methods: { get: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireMonitor], description: 'Get a list of all alarms for monitored users', async method(req, res) { // execute the query asynchronously and manually construct a response, for scaling reasons const users = DbUser.find({ - _id: { $in: req.user.monitoredUsers } + _id: { $in: req.user.monitoredUsers }, + alarm: { $exists: true, $ne: null } }) .lean() .cursor(); res.status(200); res.write('{\n'); + let first = true; for await (const user of users) { - if (user.alarm) { - res.write( - `"${user._id}": ${JSON.stringify(user.alarm)},\n` - ); - } + res.write( + `${first ? '' : ','}"${user._id}": ${JSON.stringify(user.alarm)}\n` + ); + first = false; } res.write('}'); res.end(); @@ -76,15 +86,24 @@ export const alarmRoute = { path: '/alarm/@me', methods: { get: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireUser], description: "Get the current user's alarm", async method(req, res) { - res.send(req.user.alarm); + res.send(req.user.alarm ?? 'null'); } }), put: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, + exampleBody: { + reason: 'fall' + }, middlewares: [requireUser], - description: 'Raise an alarm', + description: 'Raise an alarm (enum: one of "fall" or "toilet")', async method(req, res) { req.user.alarm = await AlarmDto.create(req.body); await req.user.save(); @@ -92,6 +111,9 @@ export const alarmRoute = { } }), delete: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireUser], description: 'Clear alarm', async method(req, res) { diff --git a/src/api/routes/assignedUserRoutes.js b/src/api/routes/assignedUserRoutes.js
index dac9b13..e870cd8 100644 --- a/src/api/routes/assignedUserRoutes.js +++ b/src/api/routes/assignedUserRoutes.js
@@ -10,6 +10,9 @@ export const assignedUserRoute = { path: '/monitor/assignedUsers', methods: { get: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireMonitor], description: 'Get assigned users', async method(req, res) { @@ -17,7 +20,13 @@ export const assignedUserRoute = { } }), patch: { + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireMonitor], + exampleBody: { + userId: 'abcdef' + }, description: 'Add an assigned user by ID', async method(req, res) { if (!req.body.userId) { @@ -41,6 +50,9 @@ export const assignedUserRoute = { } }, delete: { + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireMonitor], description: 'Remove an assigned user by ID', async method(req, res) { diff --git a/src/api/routes/auth/accountRoutes.js b/src/api/routes/auth/accountRoutes.js
index 34592ed..f0d0102 100644 --- a/src/api/routes/auth/accountRoutes.js +++ b/src/api/routes/auth/accountRoutes.js
@@ -3,6 +3,7 @@ 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'; +import { SafeNSoundError } from '#util/error.js'; /** * @type {RouteDescription} @@ -59,13 +60,17 @@ export const logoutRoute = { path: '/auth/logout', methods: { post: new RouteMethod({ - description: 'Log out from a device (TODO)', + description: 'Log out from a device', exampleHeaders: { Authorization: 'Bearer {{accessToken}}' }, + middlewares: [requireAuth], async method(req, res) { - const data = await AuthDto.create(req.body); - // const loginResult = await deleteDevice(data, ); + const deviceIndex = req.user.devices.findIndex( + device => device.id === req.device._id + ); + req.user.devices.splice(deviceIndex, 1); + await req.user.save(); res.status(204).send(); } }) @@ -102,6 +107,9 @@ export const whoAmI = { methods: { get: new RouteMethod({ description: 'Get current user', + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireAuth], async method(req, res) { const data = await WhoAmIDto.create({ diff --git a/src/api/routes/auth/adminAccountRoutes.js b/src/api/routes/auth/adminAccountRoutes.js deleted file mode 100644
index b485002..0000000 --- a/src/api/routes/auth/adminAccountRoutes.js +++ /dev/null
@@ -1,58 +0,0 @@ -import { - DbUser, - deleteUser, - getUserById, - loginUser, - registerUser, - UserType -} from '#db/index.js'; -import { AuthDto, RegisterDto } from '#dto/index.js'; -import { requireAdmin, requireRole } from '#api/middlewares/index.js'; -import { RouteDescription, RouteMethod } from '#api/RouteDescription.js'; - -/** - * @type {RouteDescription} - */ -export const adminGetUsersRoute = { - path: '/admin/users', - methods: { - get: new RouteMethod({ - description: 'Get all users (raw)', - exampleHeaders: { - Authorization: 'Bearer {{accessToken}}' - }, - middlewares: [requireAdmin], - async method(req, res) { - res.send(DbUser.find({}).exec()); - } - }) - } -}; - -/** - * @type {RouteDescription} - */ -export const adminUserRoute = { - path: '/admin/user/:id', - methods: { - get: new RouteMethod({ - description: 'Get a user (raw)', - exampleHeaders: { - Authorization: 'Bearer {{accessToken}}' - }, - middlewares: [requireAdmin], - async method(req, res) { - const user = await getUserById(req.params.id); - res.send(user); - } - }), - delete: new RouteMethod({ - middlewares: [requireAdmin], - description: 'Delete a user', - async method(req, res) { - await deleteUser(data); - res.status(204).send(); - } - }) - } -}; diff --git a/src/api/routes/auth/deviceRoutes.js b/src/api/routes/auth/deviceRoutes.js
index 551252b..41802b8 100644 --- a/src/api/routes/auth/deviceRoutes.js +++ b/src/api/routes/auth/deviceRoutes.js
@@ -1,7 +1,11 @@ -import { registerUser } from '#db/index.js'; -import { RegisterDto } from '#dto/index.js'; import { requireAuth } from '#api/middlewares/index.js'; import { RouteMethod } from '#api/RouteDescription.js'; +import { SafeNSoundError } from '#util/error.js'; +import Joi from 'joi'; + +const deviceUpdateSchema = Joi.object({ + name: Joi.string().optional().max(100) +}); /** * @type {RouteDescription} @@ -10,12 +14,97 @@ export const getDevicesRoute = { path: '/auth/devices', methods: { get: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireAuth], description: 'Get all devices registered to the user', async method(req, res) { - const data = await RegisterDto.create(req.body); - const registerResult = await registerUser(data); - res.send(registerResult); + res.send(req.user.devices); + } + }) + } +}; + +/** + * @type {RouteDescription} + */ +export const manageDeviceRoute = { + path: '/auth/devices/:id', + methods: { + get: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, + middlewares: [requireAuth], + description: 'Get user device by ID', + async method(req, res) { + const device = req.user.devices.find( + device => device.id === req.params.id + ); + if (!device) { + res.status(404).send( + new SafeNSoundError({ + errCode: 'ENTITY_NOT_FOUND', + message: 'Device not found' + }) + ); + return; + } + res.send(device); + } + }), + delete: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, + middlewares: [requireAuth], + description: 'Delete user device by ID', + async method(req, res) { + const deviceIndex = req.user.devices.findIndex( + device => device.id === req.params.id + ); + if (deviceIndex === -1) { + res.status(404).send( + new SafeNSoundError({ + errCode: 'ENTITY_NOT_FOUND', + message: 'Device not found' + }) + ); + return; + } + req.user.devices.splice(deviceIndex, 1); + await req.user.save(); + res.status(204).send(); + } + }), + patch: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, + exampleBody: { + name: 'New Device Name' + }, + middlewares: [requireAuth], + description: 'Update user device by ID', + async method(req, res) { + const device = req.user.devices.find( + device => device.id === req.params.id + ); + if (!device) { + res.status(404).send( + new SafeNSoundError({ + errCode: 'ENTITY_NOT_FOUND', + message: 'Device not found' + }) + ); + return; + } + if (req.body.name) { + device.name = req.body.name; + } + await req.user.save(); + res.send(device); } }) } diff --git a/src/api/routes/auth/index.js b/src/api/routes/auth/index.js
index 2d2cc86..e687911 100644 --- a/src/api/routes/auth/index.js +++ b/src/api/routes/auth/index.js
@@ -1,3 +1,2 @@ export * from './accountRoutes.js'; export * from './deviceRoutes.js'; -export * from './adminAccountRoutes.js'; diff --git a/src/api/routes/budgetRoutes.js b/src/api/routes/budgetRoutes.js
index bcb9711..e522442 100644 --- a/src/api/routes/budgetRoutes.js +++ b/src/api/routes/budgetRoutes.js
@@ -3,7 +3,7 @@ import { requireUser, requireRole } from '#api/middlewares/index.js'; -import { UserType } from '#db/schemas/index.js'; +import { DbSpendHistory, UserType } from '#db/schemas/index.js'; import { RouteDescription, RouteMethod, @@ -11,14 +11,25 @@ import { } from '#api/RouteDescription.js'; import { getUserById } from '#db/dbAccess/index.js'; import { SafeNSoundError } from '#util/error.js'; +import Joi from 'joi'; + +const budgetModifySchema = new Joi.object({ + venue: Joi.string().required().max(100), + reason: Joi.string().required().max(500), + amount: Joi.number().required().min(0), + createdAt: Joi.forbidden() +}); /** * @type {RouteDescription} */ export const getBudgetByUserRoute = { - path: '/budget/:id', + path: '/user/:id/budget', methods: { get: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireMonitor], description: 'Get the budget for a monitored user', async method(req, res) { @@ -33,40 +44,40 @@ export const getBudgetByUserRoute = { const user = await getUserById(req.params.id); res.send({ balance: user.balance }); } - }) - } -}; - -/** - * @type {RouteDescription} - */ -export const addBudgetByUserRoute = { - path: '/budget/:id/add', - methods: { - get: new RouteMethod({ - description: 'Add budget to a monitored user', + }), + patch: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, + exampleBody: { + venue: 'Monitor 123', + reason: 'Just short for a coke to deal with diabetes', + amount: 0.15 + }, middlewares: [requireMonitor], + description: 'Add budget for a monitored user', async method(req, res) { - if (req.user.type !== UserType.ADMIN) { - if (!req.user.monitoredUsers.includes(req.params.id)) - throw new SafeNSoundError({ - errCode: 'UNAUTHORIZED', - message: - "You do not have permission to add budget to this user's account." - }); - } - - const user = await getUserById(req.params.id); - const amount = parseFloat(req.query.amount); - if (isNaN(amount) || amount <= 0) { + if ( + req.user.type !== UserType.ADMIN && + !req.user.monitoredUsers.includes(req.params.id) + ) throw new SafeNSoundError({ - errCode: 'INVALID_AMOUNT', - message: 'Invalid amount specified.' + errCode: 'UNAUTHORIZED', + message: + "You do not have permission to update this user's budget." }); - } - user.balance += amount; + let data = await budgetModifySchema.validateAsync(req.body); + const user = await getUserById(req.params.id); + user.balance += data.amount; + let histEntry = await DbSpendHistory.create({ + venue: data.venue, + reason: data.reason, + amount: data.amount + }); + user.spendHistory.push(histEntry._id); await user.save(); + res.send({ balance: user.balance }); } }) @@ -76,14 +87,51 @@ export const addBudgetByUserRoute = { /** * @type {RouteDescription} */ -export const getBudgetRoute = { +export const userBudgetRoute = { path: '/budget/@me', methods: { get: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, middlewares: [requireUser], async method(req, res) { res.send({ currentBalance: req.user.balance }); } + }), + patch: new RouteMethod({ + exampleHeaders: { + Authorization: 'Bearer {{accessToken}}' + }, + exampleBody: { + venue: 'The Store', + reason: 'Bought a coke', + amount: 0.85 + }, + middlewares: [requireUser], + description: 'Spend part of budget', + async method(req, res) { + let data = await budgetModifySchema.validateAsync(req.body); + + if (data.amount > req.user.balance) { + throw new SafeNSoundError({ + errCode: 'INSUFFICIENT_FUNDS', + message: + 'You do not have enough funds to complete this transaction.' + }); + } + + req.user.balance -= data.amount; + let histEntry = await DbSpendHistory.create({ + venue: data.venue, + reason: data.reason, + amount: data.amount + }); + req.user.spendHistory.push(histEntry._id); + await req.user.save(); + + res.send({ balance: req.user.balance }); + } }) } }; diff --git a/src/db/schemas/sensorHistory.js b/src/db/schemas/sensorHistory.js
index 152f03a..e016079 100644 --- a/src/db/schemas/sensorHistory.js +++ b/src/db/schemas/sensorHistory.js
@@ -6,11 +6,6 @@ import { model, Schema, ObjectId } from 'mongoose'; */ export const sensorHistorySchema = new Schema( { - createdAt: { - type: Date, - default: Date.now, - immutable: true - }, sensor: { type: String, required: true, @@ -23,6 +18,10 @@ export const sensorHistorySchema = new Schema( } }, { + timestamps: { + createdAt: true, + updatedAt: false + }, timeseries: { timeField: 'createdAt' } diff --git a/src/db/schemas/spendHistory.js b/src/db/schemas/spendHistory.js
index b4c3f20..d1d6c2a 100644 --- a/src/db/schemas/spendHistory.js +++ b/src/db/schemas/spendHistory.js
@@ -11,23 +11,22 @@ export const spendHistorySchema = new Schema( required: true, immutable: true }, - items: { - type: [String], + reason: { + type: String, required: true, immutable: true }, - cost: { + amount: { type: Number, required: true, immutable: true - }, - createdAt: { - type: Date, - default: Date.now, - immutable: true } }, { + timestamps: { + createdAt: true, + updatedAt: false + }, timeseries: { timeField: 'createdAt' } diff --git a/src/db/schemas/user.js b/src/db/schemas/user.js
index 69ebb02..55e2e67 100644 --- a/src/db/schemas/user.js +++ b/src/db/schemas/user.js
@@ -11,23 +11,26 @@ export const AlarmType = Object.freeze({ TOILET: 'toilet' }); -export const deviceSchema = new Schema({ - name: { - type: String, - required: true, - trim: true - }, - createdAt: { - type: Date, - default: Date.now, - immutable: true +export const deviceSchema = new Schema( + { + name: { + type: String, + required: true, + trim: true + }, + lastSeen: { + type: Date, + default: Date.now, + required: true + } }, - lastSeen: { - type: Date, - default: Date.now, - required: true + { + timestamps: { + createdAt: true, + updatedAt: false + } } -}); +); export const alarmSchema = new Schema( { @@ -49,66 +52,69 @@ export const alarmSchema = new Schema( * User schema for MongoDB. * @type {module:mongoose.Schema} */ -export const userSchema = new Schema({ - username: { - type: String, - required: true, - unique: true, - trim: true - }, - passwordHash: { - type: String, - required: true - }, - email: { - type: String, - required: true, - unique: true, - trim: true - }, - type: { - type: String, - enum: Object.values(UserType), - default: 'user' - }, - createdAt: { - type: Date, - default: Date.now, - immutable: true - }, - devices: { - type: [deviceSchema], - default: [] - }, - alarm: { - type: alarmSchema - }, - monitoredUsers: { - type: [ObjectId], - ref: 'users' - }, - balance: { - type: Number, - default: 0 - }, - spendHistory: { - type: [ObjectId], - ref: 'spendHistory' - }, - emergencyContacts: { - type: String - }, - medicalInfo: { - type: String +export const userSchema = new Schema( + { + username: { + type: String, + required: true, + unique: true, + trim: true + }, + passwordHash: { + type: String, + required: true + }, + email: { + type: String, + required: true, + unique: true, + trim: true + }, + type: { + type: String, + enum: Object.values(UserType), + default: 'user' + }, + devices: { + type: [deviceSchema], + default: [] + }, + alarm: { + type: alarmSchema + }, + monitoredUsers: { + type: [ObjectId], + ref: 'users' + }, + balance: { + type: Number, + default: 0 + }, + spendHistory: { + type: [ObjectId], + ref: 'spendHistory' + }, + emergencyContacts: { + type: String + }, + medicalInfo: { + type: String + }, + location: { + // https://stackoverflow.com/a/27218808 + // Longtitute, Latitude info + type: [Number], + index: { type: '2dsphere', sparse: true }, + count: 2 + } }, - location: { - // https://stackoverflow.com/a/27218808 - // Longtitute, Latitude info - type: [Number], - index: { type: '2dsphere', sparse: true }, - count: 2 + { + timestamps: { + createdAt: true, + updatedAt: false + } } -}); +); export const DbUser = model('user', userSchema); diff --git a/src/dto/auth/RegisterDto.js b/src/dto/auth/RegisterDto.js
index 40f1959..fa319b2 100644 --- a/src/dto/auth/RegisterDto.js +++ b/src/dto/auth/RegisterDto.js
@@ -22,15 +22,6 @@ export class RegisterDto { } } - try { - return await RegisterDto.schema.validateAsync(obj); - } catch (e) { - console.log(e); - throw new SafeNSoundError({ - errCode: 'JOI_VALIDATION_ERROR', - message: e.message, - validation_details: e.details - }); - } + return await RegisterDto.schema.validateAsync(obj); } } diff --git a/testFrontend/SafeNSound.FakeUser/MonitorService.cs b/testFrontend/SafeNSound.FakeUser/MonitorService.cs
index 57d90a5..83c9c29 100644 --- a/testFrontend/SafeNSound.FakeUser/MonitorService.cs +++ b/testFrontend/SafeNSound.FakeUser/MonitorService.cs
@@ -1,14 +1,21 @@ +using System.Net; using ArcaneLibs.Extensions; namespace SafeNSound.FakeUser; -public class MonitorService(ILogger<MonitorService> logger, UserStore userStore): IHostedService { - private Task? _getAllAlarmsTask, _assignBudgetTask; +public class MonitorService(ILogger<MonitorService> logger, UserStore userStore) : IHostedService { + private Task? _getAllAlarmsTask, + _assignBudgetTask, + _getAlarmForRandomUserTask, + _rotateMonitoredUsersTask; + private readonly CancellationTokenSource _cts = new(); - + public async Task StartAsync(CancellationToken cancellationToken) { _getAllAlarmsTask = GetAllAlarms(_cts.Token); _assignBudgetTask = AssignBudget(_cts.Token); + _getAlarmForRandomUserTask = GetAlarmForRandomUser(_cts.Token); + _rotateMonitoredUsersTask = RotateMonitoredUsers(_cts.Token); } private async Task GetAllAlarms(CancellationToken cancellationToken) { @@ -16,37 +23,96 @@ public class MonitorService(ILogger<MonitorService> logger, UserStore userStore) try { var user = userStore.GetRandomMonitor(); var alarms = await user.Client!.GetAllAlarms(); - if(alarms.Count > 0) + 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 user.Client!.GetAlarm(alarms.Keys.First()); + await user.Client!.DeleteAlarm(alarms.Keys.First()); + } + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - } catch (Exception ex) { - logger.LogError(ex, "Error querying alarm"); + } + catch (Exception ex) { + logger.LogError(ex, "Error querying alarm list"); } } } - - private async Task AssignBudget(CancellationToken cancellationToken) { + + private async Task GetAlarmForRandomUser(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); + var watchedUserIds = await user.Client!.GetAssignedUsers(); + if (watchedUserIds.Count == 0) { + logger.LogInformation("Monitor {UserId} has no assigned users", user.Auth.Username); + continue; + } - } catch (Exception ex) { - logger.LogError(ex, "Error querying alarm"); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + catch (Exception ex) { + logger.LogError(ex, "Error querying alarm for user"); } + } + } + + private async Task RotateMonitoredUsers(CancellationToken cancellationToken) { + while (!cancellationToken.IsCancellationRequested) { + try { + var user = userStore.GetRandomMonitor(); + var watchedUserIds = (await user.Client!.GetAssignedUsers()).ToArray(); + if (watchedUserIds.Length == 0) { + logger.LogInformation("Monitor {UserId} has no assigned users", user.Auth.Username); + } + else { + var idToRemove = Random.Shared.GetItems(watchedUserIds, 1).First(); + await user.Client!.RemoveAssignedUser(idToRemove); + } + + string idToAdd; + do { + idToAdd = userStore.GetRandomUserOfAnyType().WhoAmI!.UserId; + } while (watchedUserIds.Contains(idToAdd)); + await user.Client!.AddAssignedUser(idToAdd); + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + catch (Exception ex) { + logger.LogError(ex, "Error rotating monitored users"); + } + } + } + + private async Task AssignBudget(CancellationToken cancellationToken) { + while (!cancellationToken.IsCancellationRequested) { + try { + var user = userStore.GetRandomMonitor(); + var monitoredUsers = (await user.Client!.GetAssignedUsers()).ToArray(); + if (monitoredUsers.Length == 0) { + logger.LogInformation("Monitor {UserId} has no assigned users", user.Auth.Username); + continue; + } + var userId = Random.Shared.GetItems(monitoredUsers, 1).First(); + var budget = Random.Shared.NextDouble(); + await user.Client!.GetBudget(userId); + await user.Client!.AddBudget(userId, new() { + Amount = budget, + Reason = "Random budget assignment", + Venue = "FakeUser" + }); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + catch (Exception ex) { + logger.LogError(ex, "Error assigning budget"); + } } } public async Task StopAsync(CancellationToken cancellationToken) { await _cts.CancelAsync(); await _getAllAlarmsTask!; + await _assignBudgetTask!; + await _getAlarmForRandomUserTask!; + await _rotateMonitoredUsersTask!; } } \ No newline at end of file diff --git a/testFrontend/SafeNSound.FakeUser/Program.cs b/testFrontend/SafeNSound.FakeUser/Program.cs
index 7c3eaff..0852e6f 100644 --- a/testFrontend/SafeNSound.FakeUser/Program.cs +++ b/testFrontend/SafeNSound.FakeUser/Program.cs
@@ -15,7 +15,7 @@ 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<UserService>(); builder.Services.AddHostedService<MonitorService>(); // WrappedHttpClient.LogRequests = false; diff --git a/testFrontend/SafeNSound.FakeUser/RandomAlarmService.cs b/testFrontend/SafeNSound.FakeUser/RandomAlarmService.cs deleted file mode 100644
index 7835f89..0000000 --- a/testFrontend/SafeNSound.FakeUser/RandomAlarmService.cs +++ /dev/null
@@ -1,35 +0,0 @@ -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/UserService.cs b/testFrontend/SafeNSound.FakeUser/UserService.cs new file mode 100644
index 0000000..e717458 --- /dev/null +++ b/testFrontend/SafeNSound.FakeUser/UserService.cs
@@ -0,0 +1,59 @@ +namespace SafeNSound.FakeUser; + +public class UserService(ILogger<UserService> logger, UserStore userStore) : IHostedService { + private Task _alarmTask, _spendBudgetTask; + private readonly CancellationTokenSource _cts = new(); + + public async Task StartAsync(CancellationToken cancellationToken) { + _alarmTask = ManageAlarms(_cts.Token); + _spendBudgetTask = AssignBudget(_cts.Token); + } + + private static readonly string[] validReasons = ["fall", "toilet"]; + + private async Task ManageAlarms(CancellationToken cancellationToken) { + while (!cancellationToken.IsCancellationRequested) { + try { + var user = userStore.GetRandomUser(); + var currentAlarm = await user.Client!.GetAlarm(); + if (currentAlarm is null) { + await user.Client!.SetAlarm(new Sdk.AlarmDto { + Reason = Random.Shared.GetItems(validReasons, 1).First() + }); + } + else { + await user.Client!.DeleteAlarm(); + } + } + catch (Exception ex) { + logger.LogError(ex, "Error setting/deleting alarm"); + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + } + } + + private async Task AssignBudget(CancellationToken cancellationToken) { + while (!cancellationToken.IsCancellationRequested) { + try { + var user = userStore.GetRandomUser(); + var budget = await user.Client!.GetBudget(); + await user.Client!.SpendBudget(new() { + Amount = Math.Min(budget.Amount, Random.Shared.NextDouble()), + Reason = "Random budget spending", + Venue = "The Store" + }); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + catch (Exception ex) { + logger.LogError(ex, "Error spending budget"); + } + } + } + + public async Task StopAsync(CancellationToken cancellationToken) { + await _cts.CancelAsync(); + await _alarmTask; + await _spendBudgetTask; + } +} \ No newline at end of file diff --git a/testFrontend/SafeNSound.FakeUser/UserStore.cs b/testFrontend/SafeNSound.FakeUser/UserStore.cs
index 9b04efb..f643a68 100644 --- a/testFrontend/SafeNSound.FakeUser/UserStore.cs +++ b/testFrontend/SafeNSound.FakeUser/UserStore.cs
@@ -5,7 +5,7 @@ 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> Users { get; } = Enumerable.Range(0, 15).Select(_ => new ClientContainer()).ToList(); public List<ClientContainer> AllUsers => [.. Users, .. Monitors, .. Admins]; public ClientContainer GetRandomUser() { @@ -51,8 +51,16 @@ public class UserStore(SafeNSoundAuthentication authService, SafeNSoundConfigura 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(); + container.Client = new SafeNSoundClient(config, (await authService.Login(container.Auth)).AccessToken); + container.WhoAmI = await container.Client.WhoAmI(); + await container.Client.UpdateDevice(container.WhoAmI.DeviceId, new() { + Name = "FakeUser-" + container.Auth.Username, + }); + + if (container.Auth.UserType == "admin") { + await container.Client.MonitorAllUsers(); + } + ss.Release(); }).ToList(); await Task.WhenAll(tasks); @@ -76,6 +84,10 @@ public class UserStore(SafeNSoundAuthentication authService, SafeNSoundConfigura private async Task Cleanup(ClientContainer container) { if (container.Client == null) return; try { + _ = await container.Client.GetDevices(); + if(Random.Shared.Next(100)>50) + await container.Client.DeleteDevice(container.WhoAmI!.DeviceId); + else await container.Client.LogOut(); await container.Client.DeleteAccount(container.Auth); } catch { diff --git a/testFrontend/SafeNSound.Frontend/Pages/Auth.razor b/testFrontend/SafeNSound.Frontend/Pages/Auth.razor
index 5540f02..20625f7 100644 --- a/testFrontend/SafeNSound.Frontend/Pages/Auth.razor +++ b/testFrontend/SafeNSound.Frontend/Pages/Auth.razor
@@ -3,13 +3,13 @@ <h1>Auth</h1> <u>User:</u><br/> <span>Username (L?, R): </span> -<FancyTextBox @bind-Value="@Username"/><br/> +<FancyTextBox @bind-Value="@AuthData.Username"/><br/> <span>Email (L? R): </span> -<FancyTextBox @bind-Value="@Email"/><br/> +<FancyTextBox @bind-Value="@AuthData.Email"/><br/> <span>Password (L, R): </span> -<FancyTextBox @bind-Value="@Password" IsPassword="true"/><br/> +<FancyTextBox @bind-Value="@AuthData.Password" IsPassword="true"/><br/> <span>Type (R): </span> -<FancyTextBox @bind-Value="@UserType"/><span> (one of user|monitor|admin)</span><br/> +<FancyTextBox @bind-Value="@AuthData.UserType"/><span> (one of user|monitor|admin)</span><br/> <LinkButton OnClick="@Randomise">Randomise</LinkButton> <LinkButton OnClick="@Register">Register</LinkButton> <LinkButton OnClick="@Login">Login</LinkButton> @@ -24,6 +24,20 @@ <LinkButton OnClick="@GetAssignedUsers">Get</LinkButton> <LinkButton OnClick="@AddAssignedUser">Add</LinkButton> <LinkButton OnClick="@RemoveAssignedUser">Remove</LinkButton> +<br/><br/> + +<u>Devices:</u><br/> +@if (CurrentDevice is not null) { + <span>Device ID: @CurrentDevice.Id</span><br/> + <span>Log in date: @CurrentDevice.CreatedAt</span><br/> + <span>Last seen: @CurrentDevice.LastSeen</span><br/> + <span>Device name: </span> + <FancyTextBox @bind-Value="@CurrentDevice.Name"/><br/> + <LinkButton OnClick="@GetDevice">Get</LinkButton> + <LinkButton OnClick="@UpdateDevice">Update</LinkButton> + <LinkButton OnClick="@DeleteDevice">Delete</LinkButton> + <LinkButton OnClick="@GetAllDevices">Get all</LinkButton> +} @if (Exception != null) { <div class="alert alert-danger"> @@ -44,10 +58,15 @@ } @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; } = string.Empty; + + private RegisterDto AuthData { get; set; } = new() { + Username = string.Empty, + UserType = string.Empty, + Email = String.Empty, + Password = string.Empty + }; + + private DeviceDto? CurrentDevice { get; set; } private string TargetUserId { get; set; } = string.Empty; @@ -55,10 +74,10 @@ private object? Result { get; set; } private async Task Randomise() { - Username = Guid.NewGuid().ToString(); - Email = Guid.NewGuid() + "@example.com"; - Password = Guid.NewGuid().ToString(); - UserType = Random.Shared.GetItems(["user", "monitor", "admin"], 1)[0]; + AuthData.Username = Guid.NewGuid().ToString(); + AuthData.Email = Guid.NewGuid() + "@example.com"; + AuthData.Password = Guid.NewGuid().ToString(); + AuthData.UserType = Random.Shared.GetItems(["user", "monitor", "admin"], 1)[0]; StateHasChanged(); } @@ -67,10 +86,10 @@ Exception = null; try { await Authentication.Register(new() { - Username = Username, - Password = Password, - Email = Email, - UserType = UserType + Username = AuthData.Username, + Password = AuthData.Password, + Email = AuthData.Email, + UserType = AuthData.UserType }); } catch (Exception ex) { @@ -86,11 +105,14 @@ try { AuthResult result; Result = result = await Authentication.Login(new() { - Username = Username, - Password = Password, - Email = Email + Username = AuthData.Username, + Password = AuthData.Password, + Email = AuthData.Email }); App.Client = new SafeNSoundClient(Config, result.AccessToken); + CurrentDevice = await App.Client.GetDevice( + (await App.Client.WhoAmI()).DeviceId + ); } catch (Exception ex) { Exception = ex; @@ -104,9 +126,9 @@ Exception = null; try { await Authentication.Delete(new() { - Username = Username, - Password = Password, - Email = Email + Username = AuthData.Username, + Password = AuthData.Password, + Email = AuthData.Email }); } catch (Exception ex) { @@ -125,6 +147,7 @@ catch (Exception ex) { Exception = ex; } + StateHasChanged(); } @@ -137,6 +160,7 @@ catch (Exception ex) { Exception = ex; } + StateHasChanged(); } @@ -150,6 +174,7 @@ catch (Exception ex) { Exception = ex; } + StateHasChanged(); } @@ -163,6 +188,7 @@ catch (Exception ex) { Exception = ex; } + StateHasChanged(); } @@ -185,6 +211,28 @@ catch (Exception ex) { Exception = ex; } + + StateHasChanged(); + } + + private async Task GetDevice() { + Result = CurrentDevice = await App.Client!.GetDevice(CurrentDevice!.Id!); + StateHasChanged(); + } + + private async Task UpdateDevice() { + await App.Client!.UpdateDevice(CurrentDevice!.Id!, new() { + Name = CurrentDevice.Name + }); + await GetDevice(); + } + + private async Task DeleteDevice() { + await App.Client!.DeleteDevice(CurrentDevice!.Id!); + } + + private async Task GetAllDevices() { + Result = await App.Client!.GetDevices(); StateHasChanged(); } diff --git a/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs b/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs
index 8291178..e1564db 100644 --- a/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs +++ b/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs
@@ -4,50 +4,52 @@ using System.Text.Json.Serialization; namespace SafeNSound.Sdk; -public class SafeNSoundClient(SafeNSoundConfiguration config, string accessToken) -{ - public WrappedHttpClient HttpClient { get; } = new() - { +public class SafeNSoundClient(SafeNSoundConfiguration config, string accessToken) { + public WrappedHttpClient HttpClient { get; } = new() { BaseAddress = new Uri(config.BaseUri), - DefaultRequestHeaders = - { + DefaultRequestHeaders = { Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken) } }; - - public async Task<WhoAmI> WhoAmI() - { + + public async Task<WhoAmI> WhoAmI() { var res = await HttpClient.GetAsync("/auth/whoami"); res.EnsureSuccessStatusCode(); return (await res.Content.ReadFromJsonAsync<WhoAmI>())!; } - + #region Alarm - public async Task<AlarmDto> GetAlarm(string userId = "@me") { - var res = await HttpClient.GetAsync($"/alarm/{userId}"); + public async Task SetAlarm(AlarmDto alarm) { + var res = await HttpClient.PutAsJsonAsync("/alarm/@me", alarm); res.EnsureSuccessStatusCode(); - return (await res.Content.ReadFromJsonAsync<AlarmDto>())!; } - - public async Task SetAlarm(AlarmDto alarm, string userId = "@me") { - var res = await HttpClient.PutAsJsonAsync("/alarm/@me", alarm); + + public async Task<AlarmDto?> GetAlarm(string userId = "@me") { + var res = await HttpClient.GetAsync( + // required due to express routing not being closest-match + userId == "@me" + ? $"/alarm/@me" + : $"/user/{userId}/alarm" + ); res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync<AlarmDto?>()); } - + public async Task DeleteAlarm(string userId = "@me") { - var res = await HttpClient.DeleteAsync($"/alarm/{userId}"); + var res = await HttpClient.DeleteAsync( + // required due to express routing not being closest-match + userId == "@me" + ? $"/alarm/@me" + : $"/user/{userId}/alarm" + ); res.EnsureSuccessStatusCode(); } - - #endregion #region Budget - - #endregion public async Task<Dictionary<string, AlarmDto>> GetAllAlarms() { @@ -89,13 +91,95 @@ public class SafeNSoundClient(SafeNSoundConfiguration config, string accessToken var res = await HttpClient.PostAsync("/admin/monitorAllUsers", null); res.EnsureSuccessStatusCode(); } -} + public async Task<List<DeviceDto>> GetDevices() { + var res = await HttpClient.GetAsync("/auth/devices"); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync<List<DeviceDto>>())!; + } + + public async Task<DeviceDto> GetDevice(string deviceId) { + var res = await HttpClient.GetAsync($"/auth/devices/{deviceId}"); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync<DeviceDto>())!; + } + + public async Task DeleteDevice(string deviceId) { + var res = await HttpClient.DeleteAsync($"/auth/devices/{deviceId}"); + res.EnsureSuccessStatusCode(); + } + + public async Task UpdateDevice(string deviceId, DeviceDto device) { + var res = await HttpClient.PatchAsJsonAsync($"/auth/devices/{deviceId}", device); + res.EnsureSuccessStatusCode(); + } + + public async Task LogOut() { + var res = await HttpClient.PostAsync("/auth/logout", null); + res.EnsureSuccessStatusCode(); + } + + public async Task AddBudget(string userId, BudgetHistoryEntry budget) { + var res = await HttpClient.PatchAsJsonAsync($"/user/{userId}/budget", budget); + res.EnsureSuccessStatusCode(); + } + + public async Task SpendBudget(BudgetHistoryEntry budget) { + var res = await HttpClient.PatchAsJsonAsync($"/budget/@me", budget); + res.EnsureSuccessStatusCode(); + } + + public async Task<BudgetWithHistory> GetBudget(string userId = "@me") { + var res = await HttpClient.GetAsync( + userId == "@me" + ? $"/budget/@me" + : $"/user/{userId}/budget" + ); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync<BudgetWithHistory>())!; + } +} public class AlarmDto { [JsonPropertyName("reason")] public required string Reason { get; set; } - + + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } +} + +public class DeviceDto { + [JsonPropertyName("_id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + [JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; } + + [JsonPropertyName("lastSeen")] + public DateTime LastSeen { get; set; } +} + +public class BudgetWithHistory { + [JsonPropertyName("budget")] + public double Amount { get; set; } + + [JsonPropertyName("history")] + public List<BudgetHistoryEntry> History { get; set; } = new(); +} + +public class BudgetHistoryEntry { + [JsonPropertyName("venue")] + public string Venue { get; set; } + + [JsonPropertyName("amount")] + public double Amount { get; set; } + + [JsonPropertyName("reason")] + public 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 aa785cd..49bd212 100644 --- a/testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs +++ b/testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs
@@ -337,7 +337,7 @@ public class WrappedHttpClient { var request = new HttpRequestMessage(HttpMethod.Delete, url) { - Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") + Content = new StringContent(JsonSerializer.Serialize(payload, GetJsonSerializerOptions()), Encoding.UTF8, "application/json") }; return await SendAsync(request); } @@ -345,7 +345,7 @@ public class WrappedHttpClient 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") + Content = new StringContent(JsonSerializer.Serialize(payload, GetJsonSerializerOptions()), Encoding.UTF8, "application/json") }; return await SendAsync(request); } diff --git a/testFrontend/SafeNSound.sln.DotSettings.user b/testFrontend/SafeNSound.sln.DotSettings.user
index ae9fb74..1235bc1 100644 --- a/testFrontend/SafeNSound.sln.DotSettings.user +++ b/testFrontend/SafeNSound.sln.DotSettings.user
@@ -1,4 +1,7 @@ <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_003AHttpResponseMessage_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc04aec05cdf74c1a9565aec2103f20961a1e00_003F43_003Ff3c3d460_003FHttpResponseMessage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> + <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARandom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fba81a6af46624b56ac6210bdbcc99af2d19e00_003F48_003Fde29dea3_003FRandom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> + <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fba81a6af46624b56ac6210bdbcc99af2d19e00_003Fe4_003Fa18c424f_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <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>