Compare commits

...

9 Commits

Author SHA1 Message Date
github-actions[bot]
5d89682a3f chore(main): release 4.18.2 (#1643)
🤖 I have created a release *beep* *boop*
---


## [4.18.2](https://github.com/unraid/api/compare/v4.18.1...v4.18.2)
(2025-09-03)


### Bug Fixes

* add missing CPU guest metrics to CPU responses
([#1644](https://github.com/unraid/api/issues/1644))
([99dbad5](99dbad57d5))
* **plugin:** raise minimum unraid os version to 6.12.15
([#1649](https://github.com/unraid/api/issues/1649))
([bc15bd3](bc15bd3d70))
* update GitHub Actions token for workflow trigger
([4d8588b](4d8588b173))
* update OIDC URL validation and add tests
([#1646](https://github.com/unraid/api/issues/1646))
([c7c3bb5](c7c3bb57ea))
* use shared bg & border color for styled toasts
([#1647](https://github.com/unraid/api/issues/1647))
([7c3aee8](7c3aee8f3f))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-03 15:23:02 -04:00
Pujit Mehrotra
bc15bd3d70 fix(plugin): raise minimum unraid os version to 6.12.15 (#1649) 2025-09-03 15:20:24 -04:00
Pujit Mehrotra
7c3aee8f3f fix: use shared bg & border color for styled toasts (#1647)
Addresses user complaints about light colored notifications in dark
themes.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- New Features
- Introduced type-specific toast color tokens (success, info, warning,
error) for richer, clearer toast styling.
- Applied consistent theming across light, inverted, and dark modes to
improve readability and contrast.
  - Enabled compatibility with rich color settings for toasts.
- Bug Fixes
- Corrected a CSS comment block to ensure styles compile and apply
reliably.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-03 12:36:18 -04:00
Eli Bosley
c7c3bb57ea fix: update OIDC URL validation and add tests (#1646)
- Updated the OIDC issuer URL validation to prevent trailing slashes and
whitespace.
- Introduced a utility class `OidcUrlPatterns` for managing URL patterns
and validation logic.
- Added comprehensive tests for the new URL validation logic and
examples to ensure correctness.
- Bumped version to 4.18.1 in the configuration file.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- New Features
- Added strict validation for OIDC issuer URLs in the SSO configuration
form, with clearer guidance to avoid trailing slashes.
- Bug Fixes
- Prevented misconfiguration by rejecting issuer URLs with trailing
slashes (e.g., Google issuer), avoiding double slashes in discovery
URLs.
- Tests
- Introduced comprehensive unit tests covering issuer URL validation,
patterns, and real-world scenarios to ensure reliability.
- Chores
  - Bumped version to 4.18.1.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-03 11:56:30 -04:00
Eli Bosley
99dbad57d5 fix: add missing CPU guest metrics to CPU responses (#1644)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- New Features
- CPU load metrics now include guest runtime and hypervisor steal time
percentages, exposed as additional fields in CPU load responses
(per‑CPU).
- Tests
- Added comprehensive unit tests for CPU info and load generation,
including edge cases and validation of the new guest and steal metrics.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-03 11:06:51 -04:00
Eli Bosley
c42f79d406 chore: add code coverage monitoring (#1645) 2025-09-03 11:06:05 -04:00
Eli Bosley
4d8588b173 fix: update GitHub Actions token for workflow trigger
Replaced the token used for triggering workflows in the build-plugin.yml file from WORKFLOW_TRIGGER_PAT to UNRAID_BOT_GITHUB_ADMIN_TOKEN for improved security and access control.
2025-09-03 10:04:54 -04:00
github-actions[bot]
0d1d27064e chore(main): release 4.18.1 (#1641)
🤖 I have created a release *beep* *boop*
---


## [4.18.1](https://github.com/unraid/api/compare/v4.18.0...v4.18.1)
(2025-09-03)


### Bug Fixes

* OIDC and API Key management issues
([#1642](https://github.com/unraid/api/issues/1642))
([0fe2c2c](0fe2c2c1c8))
* rm redundant emission to `$HOME/.pm2/logs`
([#1640](https://github.com/unraid/api/issues/1640))
([a8e4119](a8e4119270))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-03 09:47:55 -04:00
Eli Bosley
0fe2c2c1c8 fix: OIDC and API Key management issues (#1642) 2025-09-03 09:47:11 -04:00
32 changed files with 1310 additions and 109 deletions

View File

@@ -152,7 +152,7 @@ jobs:
with:
workflow: release-production.yml
inputs: '{ "version": "${{ steps.vars.outputs.API_VERSION }}" }'
token: ${{ secrets.WORKFLOW_TRIGGER_PAT }}
token: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
- name: Upload to Cloudflare
if: inputs.RELEASE_CREATED == 'false'

View File

@@ -117,42 +117,62 @@ jobs:
# Verify libvirt is running using sudo to bypass group membership delays
sudo virsh list --all || true
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build UI Package First
run: |
echo "🔧 Building UI package for web tests dependency..."
cd ../unraid-ui && pnpm run build
- name: Run Tests Concurrently
run: |
set -e
# Run all tests in parallel with labeled output
# Run all tests in parallel with labeled output and coverage generation
echo "🚀 Starting API coverage tests..."
pnpm run coverage > api-test.log 2>&1 &
API_PID=$!
echo "🚀 Starting Connect plugin tests..."
(cd ../packages/unraid-api-plugin-connect && pnpm test) > connect-test.log 2>&1 &
(cd ../packages/unraid-api-plugin-connect && pnpm test --coverage 2>/dev/null || pnpm test) > connect-test.log 2>&1 &
CONNECT_PID=$!
echo "🚀 Starting Shared package tests..."
(cd ../packages/unraid-shared && pnpm test) > shared-test.log 2>&1 &
(cd ../packages/unraid-shared && pnpm test --coverage 2>/dev/null || pnpm test) > shared-test.log 2>&1 &
SHARED_PID=$!
echo "🚀 Starting Web package coverage tests..."
(cd ../web && (pnpm test --coverage || pnpm test)) > web-test.log 2>&1 &
WEB_PID=$!
echo "🚀 Starting UI package coverage tests..."
(cd ../unraid-ui && pnpm test --coverage 2>/dev/null || pnpm test) > ui-test.log 2>&1 &
UI_PID=$!
# Wait for all processes and capture exit codes
wait $API_PID && echo "✅ API tests completed" || { echo "❌ API tests failed"; API_EXIT=1; }
wait $CONNECT_PID && echo "✅ Connect tests completed" || { echo "❌ Connect tests failed"; CONNECT_EXIT=1; }
wait $SHARED_PID && echo "✅ Shared tests completed" || { echo "❌ Shared tests failed"; SHARED_EXIT=1; }
wait $WEB_PID && echo "✅ Web tests completed" || { echo "❌ Web tests failed"; WEB_EXIT=1; }
wait $UI_PID && echo "✅ UI tests completed" || { echo "❌ UI tests failed"; UI_EXIT=1; }
# Display all outputs
echo "📋 API Test Results:" && cat api-test.log
echo "📋 Connect Plugin Test Results:" && cat connect-test.log
echo "📋 Shared Package Test Results:" && cat shared-test.log
echo "📋 Web Package Test Results:" && cat web-test.log
echo "📋 UI Package Test Results:" && cat ui-test.log
# Exit with error if any test failed
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 ]]; then
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 || ${WEB_EXIT:-0} -eq 1 || ${UI_EXIT:-0} -eq 1 ]]; then
exit 1
fi
- name: Upload all coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/coverage-final.json,../web/coverage/coverage-final.json,../unraid-ui/coverage/coverage-final.json,../packages/unraid-api-plugin-connect/coverage/coverage-final.json,../packages/unraid-shared/coverage/coverage-final.json
fail_ci_if_error: false
build-api:
name: Build API
runs-on: ubuntu-latest

View File

@@ -1 +1 @@
{".":"4.18.0"}
{".":"4.18.2"}

View File

@@ -418,6 +418,23 @@
--normal-border: hsl(var(--border));
--normal-text: hsl(var(--foreground));
--success-bg: hsl(var(--background));
--success-border: hsl(var(--border));
--success-text: hsl(140, 100%, 27%);
--info-bg: hsl(var(--background));
--info-border: hsl(var(--border));
--info-text: hsl(210, 92%, 45%);
--warning-bg: hsl(var(--background));
--warning-border: hsl(var(--border));
--warning-text: hsl(31, 92%, 45%);
--error-bg: hsl(var(--background));
--error-border: hsl(var(--border));
--error-text: hsl(360, 100%, 45%);
/* Old colors, preserved for reference
--success-bg: hsl(143, 85%, 96%);
--success-border: hsl(145, 92%, 91%);
--success-text: hsl(140, 100%, 27%);
@@ -432,7 +449,7 @@
--error-bg: hsl(359, 100%, 97%);
--error-border: hsl(359, 100%, 94%);
--error-text: hsl(360, 100%, 45%);
--error-text: hsl(360, 100%, 45%); */
}
[data-sonner-toaster][data-theme='light'] [data-sonner-toast][data-invert='true'] {
@@ -452,6 +469,23 @@
--normal-border: hsl(var(--border));
--normal-text: hsl(var(--foreground));
--success-bg: hsl(var(--background));
--success-border: hsl(var(--border));
--success-text: hsl(150, 86%, 65%);
--info-bg: hsl(var(--background));
--info-border: hsl(var(--border));
--info-text: hsl(216, 87%, 65%);
--warning-bg: hsl(var(--background));
--warning-border: hsl(var(--border));
--warning-text: hsl(46, 87%, 65%);
--error-bg: hsl(var(--background));
--error-border: hsl(var(--border));
--error-text: hsl(358, 100%, 81%);
/* Old colors, preserved for reference
--success-bg: hsl(150, 100%, 6%);
--success-border: hsl(147, 100%, 12%);
--success-text: hsl(150, 86%, 65%);
@@ -466,7 +500,7 @@
--error-bg: hsl(358, 76%, 10%);
--error-border: hsl(357, 89%, 16%);
--error-text: hsl(358, 100%, 81%);
--error-text: hsl(358, 100%, 81%); */
}
[data-rich-colors='true'][data-sonner-toast][data-type='success'] {

View File

@@ -1,5 +1,24 @@
# Changelog
## [4.18.2](https://github.com/unraid/api/compare/v4.18.1...v4.18.2) (2025-09-03)
### Bug Fixes
* add missing CPU guest metrics to CPU responses ([#1644](https://github.com/unraid/api/issues/1644)) ([99dbad5](https://github.com/unraid/api/commit/99dbad57d55a256f5f3f850f9a47a6eaa6348065))
* **plugin:** raise minimum unraid os version to 6.12.15 ([#1649](https://github.com/unraid/api/issues/1649)) ([bc15bd3](https://github.com/unraid/api/commit/bc15bd3d7008acb416ac3c6fb1f4724c685ec7e7))
* update GitHub Actions token for workflow trigger ([4d8588b](https://github.com/unraid/api/commit/4d8588b17331afa45ba8caf84fcec8c0ea03591f))
* update OIDC URL validation and add tests ([#1646](https://github.com/unraid/api/issues/1646)) ([c7c3bb5](https://github.com/unraid/api/commit/c7c3bb57ea482633a7acff064b39fbc8d4e07213))
* use shared bg & border color for styled toasts ([#1647](https://github.com/unraid/api/issues/1647)) ([7c3aee8](https://github.com/unraid/api/commit/7c3aee8f3f9ba82ae8c8ed3840c20ab47f3cb00f))
## [4.18.1](https://github.com/unraid/api/compare/v4.18.0...v4.18.1) (2025-09-03)
### Bug Fixes
* OIDC and API Key management issues ([#1642](https://github.com/unraid/api/issues/1642)) ([0fe2c2c](https://github.com/unraid/api/commit/0fe2c2c1c85dcc547e4b1217a3b5636d7dd6d4b4))
* rm redundant emission to `$HOME/.pm2/logs` ([#1640](https://github.com/unraid/api/issues/1640)) ([a8e4119](https://github.com/unraid/api/commit/a8e4119270868a1dabccd405853a7340f8dcd8a5))
## [4.18.0](https://github.com/unraid/api/compare/v4.17.0...v4.18.0) (2025-09-02)

View File

@@ -1,5 +1,5 @@
{
"version": "4.17.0",
"version": "4.18.1",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],

View File

@@ -0,0 +1,252 @@
---
title: Programmatic API Key Management
description: Create, use, and delete API keys programmatically for automated workflows
sidebar_position: 4
---
# Programmatic API Key Management
This guide explains how to create, use, and delete API keys programmatically using the Unraid API CLI, enabling automated workflows and scripts.
## Overview
The `unraid-api apikey` command supports both interactive and non-interactive modes, making it suitable for:
- Automated deployment scripts
- CI/CD pipelines
- Temporary access provisioning
- Infrastructure as code workflows
:::tip[Quick Start]
Jump to the [Complete Workflow Example](#complete-workflow-example) to see everything in action.
:::
## Creating API Keys Programmatically
### Basic Creation with JSON Output
Use the `--json` flag to get machine-readable output:
```bash
unraid-api apikey --create --name "workflow key" --roles ADMIN --json
```
**Output:**
```json
{
"key": "your-generated-api-key-here",
"name": "workflow key",
"id": "generated-uuid"
}
```
### Advanced Creation with Permissions
```bash
unraid-api apikey --create \
--name "limited access key" \
--permissions "DOCKER:READ_ANY,ARRAY:READ_ANY" \
--description "Read-only access for monitoring" \
--json
```
### Handling Existing Keys
If a key with the same name exists, use `--overwrite`:
```bash
unraid-api apikey --create --name "existing key" --roles ADMIN --overwrite --json
```
:::warning[Key Replacement]
The `--overwrite` flag will permanently replace the existing key. The old key will be immediately invalidated.
:::
## Deleting API Keys Programmatically
### Non-Interactive Deletion
Delete a key by name without prompts:
```bash
unraid-api apikey --delete --name "workflow key"
```
**Output:**
```
Successfully deleted 1 API key
```
### JSON Output for Deletion
Use `--json` flag for machine-readable delete confirmation:
```bash
unraid-api apikey --delete --name "workflow key" --json
```
**Success Output:**
```json
{
"deleted": 1,
"keys": [
{
"id": "generated-uuid",
"name": "workflow key"
}
]
}
```
**Error Output:**
```json
{
"deleted": 0,
"error": "No API key found with name: nonexistent key"
}
```
### Error Handling
When the specified key doesn't exist:
```bash
unraid-api apikey --delete --name "nonexistent key"
# Output: No API keys found to delete
```
**JSON Error Output:**
```json
{
"deleted": 0,
"message": "No API keys found to delete"
}
```
## Complete Workflow Example
Here's a complete example for temporary access provisioning:
```bash
#!/bin/bash
set -e
# 1. Create temporary API key
echo "Creating temporary API key..."
KEY_DATA=$(unraid-api apikey --create \
--name "temp deployment key" \
--roles ADMIN \
--description "Temporary key for deployment $(date)" \
--json)
# 2. Extract the API key
API_KEY=$(echo "$KEY_DATA" | jq -r '.key')
echo "API key created successfully"
# 3. Use the key for operations
echo "Configuring services..."
curl -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"provider": "azure", "clientId": "your-client-id"}' \
http://localhost:3001/graphql
# 4. Clean up (always runs, even on error)
trap 'echo "Cleaning up..."; unraid-api apikey --delete --name "temp deployment key"' EXIT
echo "Deployment completed successfully"
```
## Command Reference
### Create Command Options
| Flag | Description | Example |
| ----------------------- | ----------------------- | --------------------------------- |
| `--name <name>` | Key name (required) | `--name "my key"` |
| `--roles <roles>` | Comma-separated roles | `--roles ADMIN,VIEWER` |
| `--permissions <perms>` | Resource:action pairs | `--permissions "DOCKER:READ_ANY"` |
| `--description <desc>` | Key description | `--description "CI/CD key"` |
| `--overwrite` | Replace existing key | `--overwrite` |
| `--json` | Machine-readable output | `--json` |
### Available Roles
- `ADMIN` - Full system access
- `CONNECT` - Unraid Connect features
- `VIEWER` - Read-only access
- `GUEST` - Limited access
### Available Resources and Actions
**Resources:** `ACTIVATION_CODE`, `API_KEY`, `ARRAY`, `CLOUD`, `CONFIG`, `CONNECT`, `CONNECT__REMOTE_ACCESS`, `CUSTOMIZATIONS`, `DASHBOARD`, `DISK`, `DISPLAY`, `DOCKER`, `FLASH`, `INFO`, `LOGS`, `ME`, `NETWORK`, `NOTIFICATIONS`, `ONLINE`, `OS`, `OWNER`, `PERMISSION`, `REGISTRATION`, `SERVERS`, `SERVICES`, `SHARE`, `VARS`, `VMS`, `WELCOME`
**Actions:** `CREATE_ANY`, `CREATE_OWN`, `READ_ANY`, `READ_OWN`, `UPDATE_ANY`, `UPDATE_OWN`, `DELETE_ANY`, `DELETE_OWN`
### Delete Command Options
| Flag | Description | Example |
| --------------- | ------------------------ | ----------------- |
| `--delete` | Enable delete mode | `--delete` |
| `--name <name>` | Key to delete (optional) | `--name "my key"` |
**Note:** If `--name` is omitted, the command runs interactively.
## Best Practices
:::info[Security Best Practices]
**Minimal Permissions**
- Use specific permissions instead of ADMIN role when possible
- Example: `--permissions "DOCKER:READ_ANY"` instead of `--roles ADMIN`
**Key Lifecycle Management**
- Always clean up temporary keys after use
- Store API keys securely (environment variables, secrets management)
- Use descriptive names and descriptions for audit trails
:::
### Error Handling
- Check exit codes (`$?`) after each command
- Use `set -e` in bash scripts to fail fast
- Implement proper cleanup with `trap`
### Key Naming
- Use descriptive names that include purpose and date
- Names must contain only letters, numbers, and spaces
- Unicode letters are supported
## Troubleshooting
### Common Issues
:::note[Common Error Messages]
**"API key name must contain only letters, numbers, and spaces"**
- **Solution:** Remove special characters like hyphens, underscores, or symbols
**"API key with name 'x' already exists"**
- **Solution:** Use `--overwrite` flag or choose a different name
**"Please add at least one role or permission to the key"**
- **Solution:** Specify either `--roles` or `--permissions` (or both)
:::
### Debug Mode
For troubleshooting, run with debug logging:
```bash
LOG_LEVEL=debug unraid-api apikey --create --name "debug key" --roles ADMIN
```

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.18.0",
"version": "4.18.2",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {

View File

@@ -282,4 +282,153 @@ describe('ApiKeyCommand', () => {
expect(result).toEqual([Role.ADMIN, Role.CONNECT]);
});
});
describe('JSON output functionality', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
it('should output JSON when creating key with --json flag', async () => {
const mockKey = {
id: 'test-id-123',
key: 'test-key-456',
name: 'JSON_TEST',
roles: [Role.ADMIN],
createdAt: new Date().toISOString(),
permissions: [],
};
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
await command.run([], {
name: 'JSON_TEST',
create: true,
roles: [Role.ADMIN],
json: true,
});
expect(consoleSpy).toHaveBeenCalledWith(
JSON.stringify({ key: 'test-key-456', name: 'JSON_TEST', id: 'test-id-123' })
);
expect(logService.log).not.toHaveBeenCalledWith('test-key-456');
});
it('should output JSON when fetching existing key with --json flag', async () => {
const existingKey = {
id: 'existing-id-456',
key: 'existing-key-789',
name: 'EXISTING_JSON',
roles: [Role.VIEWER],
createdAt: new Date().toISOString(),
permissions: [],
};
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(existingKey);
await command.run([], {
name: 'EXISTING_JSON',
create: false,
json: true,
});
expect(consoleSpy).toHaveBeenCalledWith(
JSON.stringify({ key: 'existing-key-789', name: 'EXISTING_JSON', id: 'existing-id-456' })
);
expect(logService.log).not.toHaveBeenCalledWith('existing-key-789');
});
it('should output JSON when deleting key with --json flag', async () => {
const existingKeys = [
{
id: 'delete-id-123',
name: 'DELETE_JSON',
key: 'delete-key-456',
roles: [Role.GUEST],
createdAt: new Date().toISOString(),
permissions: [],
},
];
vi.spyOn(apiKeyService, 'findAll').mockResolvedValue(existingKeys);
vi.spyOn(apiKeyService, 'deleteApiKeys').mockResolvedValue();
await command.run([], {
name: 'DELETE_JSON',
delete: true,
json: true,
});
expect(consoleSpy).toHaveBeenCalledWith(
JSON.stringify({
deleted: 1,
keys: [{ id: 'delete-id-123', name: 'DELETE_JSON' }],
})
);
expect(logService.log).not.toHaveBeenCalledWith('Successfully deleted 1 API key');
});
it('should output JSON error when deleting non-existent key with --json flag', async () => {
vi.spyOn(apiKeyService, 'findAll').mockResolvedValue([]);
await command.run([], {
name: 'NONEXISTENT',
delete: true,
json: true,
});
expect(consoleSpy).toHaveBeenCalledWith(
JSON.stringify({ deleted: 0, message: 'No API keys found to delete' })
);
expect(logService.log).not.toHaveBeenCalledWith('No API keys found to delete');
});
it('should not suppress creation message when not using JSON', async () => {
const mockKey = {
id: 'test-id',
key: 'test-key',
name: 'NO_JSON_TEST',
roles: [Role.ADMIN],
createdAt: new Date().toISOString(),
permissions: [],
};
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
await command.run([], {
name: 'NO_JSON_TEST',
create: true,
roles: [Role.ADMIN],
json: false,
});
expect(logService.log).toHaveBeenCalledWith('Creating API Key...');
expect(logService.log).toHaveBeenCalledWith('test-key');
expect(consoleSpy).not.toHaveBeenCalled();
});
it('should suppress creation message when using JSON', async () => {
const mockKey = {
id: 'test-id',
key: 'test-key',
name: 'JSON_SUPPRESS_TEST',
roles: [Role.ADMIN],
createdAt: new Date().toISOString(),
permissions: [],
};
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
await command.run([], {
name: 'JSON_SUPPRESS_TEST',
create: true,
roles: [Role.ADMIN],
json: true,
});
expect(logService.log).not.toHaveBeenCalledWith('Creating API Key...');
expect(consoleSpy).toHaveBeenCalledWith(
JSON.stringify({ key: 'test-key', name: 'JSON_SUPPRESS_TEST', id: 'test-id' })
);
});
});
});

View File

@@ -10,12 +10,13 @@ import { Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.mode
interface KeyOptions {
name: string;
create: boolean;
create?: boolean;
delete?: boolean;
description?: string;
roles?: Role[];
permissions?: Permission[];
overwrite?: boolean;
json?: boolean;
}
@Command({
@@ -100,46 +101,102 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`,
return true;
}
/** Prompt the user to select API keys to delete. Then, delete the selected keys. */
private async deleteKeys() {
@Option({
flags: '--json',
description: 'Output machine-readable JSON format',
})
parseJson(): boolean {
return true;
}
/** Helper to output either JSON or regular log message */
private output(message: string, jsonData?: object, jsonOutput?: boolean): void {
if (jsonOutput && jsonData) {
console.log(JSON.stringify(jsonData));
} else {
this.logger.log(message);
}
}
/** Helper to output either JSON or regular error message */
private outputError(message: string, jsonData?: object, jsonOutput?: boolean): void {
if (jsonOutput && jsonData) {
console.log(JSON.stringify(jsonData));
} else {
this.logger.error(message);
}
}
/** Delete API keys either by name (non-interactive) or by prompting user selection (interactive). */
private async deleteKeys(name?: string, jsonOutput?: boolean) {
const allKeys = await this.apiKeyService.findAll();
if (allKeys.length === 0) {
this.logger.log('No API keys found to delete');
this.output(
'No API keys found to delete',
{ deleted: 0, message: 'No API keys found to delete' },
jsonOutput
);
return;
}
const answers = await this.inquirerService.prompt<DeleteApiKeyAnswers>(
DeleteApiKeyQuestionSet.name,
{}
);
if (!answers.selectedKeys || answers.selectedKeys.length === 0) {
this.logger.log('No keys selected for deletion');
return;
let selectedKeyIds: string[];
let deletedKeys: { id: string; name: string }[] = [];
if (name) {
// Non-interactive mode: delete by name
const keyToDelete = allKeys.find((key) => key.name === name);
if (!keyToDelete) {
this.outputError(
`No API key found with name: ${name}`,
{ deleted: 0, error: `No API key found with name: ${name}` },
jsonOutput
);
process.exit(1);
}
selectedKeyIds = [keyToDelete.id];
deletedKeys = [{ id: keyToDelete.id, name: keyToDelete.name }];
} else {
// Interactive mode: prompt user to select keys
const answers = await this.inquirerService.prompt<DeleteApiKeyAnswers>(
DeleteApiKeyQuestionSet.name,
{}
);
if (!answers.selectedKeys || answers.selectedKeys.length === 0) {
this.output(
'No keys selected for deletion',
{ deleted: 0, message: 'No keys selected for deletion' },
jsonOutput
);
return;
}
selectedKeyIds = answers.selectedKeys;
deletedKeys = allKeys
.filter((key) => selectedKeyIds.includes(key.id))
.map((key) => ({ id: key.id, name: key.name }));
}
try {
await this.apiKeyService.deleteApiKeys(answers.selectedKeys);
this.logger.log(`Successfully deleted ${answers.selectedKeys.length} API keys`);
await this.apiKeyService.deleteApiKeys(selectedKeyIds);
const message = `Successfully deleted ${selectedKeyIds.length} API key${selectedKeyIds.length === 1 ? '' : 's'}`;
this.output(message, { deleted: selectedKeyIds.length, keys: deletedKeys }, jsonOutput);
} catch (error) {
this.logger.error(error as any);
const errorMessage = error instanceof Error ? error.message : String(error);
this.outputError(errorMessage, { deleted: 0, error: errorMessage }, jsonOutput);
process.exit(1);
}
}
async run(
_: string[],
options: KeyOptions = { create: false, name: '', delete: false }
): Promise<void> {
async run(_: string[], options: KeyOptions = { name: '', delete: false }): Promise<void> {
try {
if (options.delete) {
await this.deleteKeys();
await this.deleteKeys(options.name, options.json);
return;
}
const key = this.apiKeyService.findByField('name', options.name);
if (key) {
this.logger.log(key.key);
} else if (options.create) {
this.output(key.key, { key: key.key, name: key.name, id: key.id }, options.json);
} else if (options.create === true) {
// Check if we have minimum required info from flags (name + at least one role or permission)
const hasMinimumInfo =
options.name &&
@@ -153,14 +210,20 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`,
// Non-interactive mode - check if key exists and handle overwrite
const existingKey = this.apiKeyService.findByField('name', options.name);
if (existingKey && !options.overwrite) {
this.logger.error(
`API key with name '${options.name}' already exists. Use --overwrite to replace it.`
this.outputError(
`API key with name '${options.name}' already exists. Use --overwrite to replace it.`,
{
error: `API key with name '${options.name}' already exists. Use --overwrite to replace it.`,
},
options.json
);
process.exit(1);
}
}
this.logger.log('Creating API Key...');
if (!options.json) {
this.logger.log('Creating API Key...');
}
if (!options.roles && !options.permissions) {
this.logger.error('Please add at least one role or permission to the key.');
@@ -178,7 +241,7 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`,
overwrite: options.overwrite ?? false,
});
this.logger.log(key.key);
this.output(key.key, { key: key.key, name: key.name, id: key.id }, options.json);
} else {
this.logger.log('No Key Found');
process.exit(1);

View File

@@ -27,6 +27,16 @@ export class CpuLoad {
description: 'The percentage of time the CPU spent servicing hardware interrupts.',
})
percentIrq!: number;
@Field(() => Float, {
description: 'The percentage of time the CPU spent running virtual machines (guest).',
})
percentGuest!: number;
@Field(() => Float, {
description: 'The percentage of CPU time stolen by the hypervisor.',
})
percentSteal!: number;
}
@ObjectType({ implements: () => Node })

View File

@@ -0,0 +1,246 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
vi.mock('systeminformation', () => ({
cpu: vi.fn().mockResolvedValue({
manufacturer: 'Intel',
brand: 'Core i7-9700K',
vendor: 'Intel',
family: '6',
model: '158',
stepping: '12',
revision: '',
voltage: '1.2V',
speed: 3.6,
speedMin: 800,
speedMax: 4900,
cores: 16,
physicalCores: 8,
processors: 1,
socket: 'LGA1151',
cache: {
l1d: 32768,
l1i: 32768,
l2: 262144,
l3: 12582912,
},
}),
cpuFlags: vi.fn().mockResolvedValue('fpu vme de pse tsc msr pae mce cx8'),
currentLoad: vi.fn().mockResolvedValue({
avgLoad: 2.5,
currentLoad: 25.5,
currentLoadUser: 15.0,
currentLoadSystem: 8.0,
currentLoadNice: 0.5,
currentLoadIdle: 74.5,
currentLoadIrq: 1.0,
currentLoadSteal: 0.2,
currentLoadGuest: 0.3,
rawCurrentLoad: 25500,
rawCurrentLoadUser: 15000,
rawCurrentLoadSystem: 8000,
rawCurrentLoadNice: 500,
rawCurrentLoadIdle: 74500,
rawCurrentLoadIrq: 1000,
rawCurrentLoadSteal: 200,
rawCurrentLoadGuest: 300,
cpus: [
{
load: 30.0,
loadUser: 20.0,
loadSystem: 10.0,
loadNice: 0,
loadIdle: 70.0,
loadIrq: 0,
loadSteal: 0,
loadGuest: 0,
rawLoad: 30000,
rawLoadUser: 20000,
rawLoadSystem: 10000,
rawLoadNice: 0,
rawLoadIdle: 70000,
rawLoadIrq: 0,
rawLoadSteal: 0,
rawLoadGuest: 0,
},
{
load: 21.0,
loadUser: 15.0,
loadSystem: 6.0,
loadNice: 0,
loadIdle: 79.0,
loadIrq: 0,
loadSteal: 0,
loadGuest: 0,
rawLoad: 21000,
rawLoadUser: 15000,
rawLoadSystem: 6000,
rawLoadNice: 0,
rawLoadIdle: 79000,
rawLoadIrq: 0,
rawLoadSteal: 0,
rawLoadGuest: 0,
},
],
}),
}));
describe('CpuService', () => {
let service: CpuService;
beforeEach(() => {
service = new CpuService();
});
describe('generateCpu', () => {
it('should return CPU information with correct structure', async () => {
const result = await service.generateCpu();
expect(result).toEqual({
id: 'info/cpu',
manufacturer: 'Intel',
brand: 'Core i7-9700K',
vendor: 'Intel',
family: '6',
model: '158',
stepping: 12,
revision: '',
voltage: '1.2V',
speed: 3.6,
speedmin: 800,
speedmax: 4900,
cores: 8,
threads: 16,
processors: 1,
socket: 'LGA1151',
cache: {
l1d: 32768,
l1i: 32768,
l2: 262144,
l3: 12582912,
},
flags: ['fpu', 'vme', 'de', 'pse', 'tsc', 'msr', 'pae', 'mce', 'cx8'],
});
});
it('should handle missing speed values', async () => {
const { cpu } = await import('systeminformation');
vi.mocked(cpu).mockResolvedValueOnce({
manufacturer: 'Intel',
brand: 'Core i7-9700K',
vendor: 'Intel',
family: '6',
model: '158',
stepping: '12',
revision: '',
voltage: '1.2V',
speed: 3.6,
cores: 16,
physicalCores: 8,
processors: 1,
socket: 'LGA1151',
cache: { l1d: 32768, l1i: 32768, l2: 262144, l3: 12582912 },
} as any);
const result = await service.generateCpu();
expect(result.speedmin).toBe(-1);
expect(result.speedmax).toBe(-1);
});
it('should handle cpuFlags error gracefully', async () => {
const { cpuFlags } = await import('systeminformation');
vi.mocked(cpuFlags).mockRejectedValueOnce(new Error('flags error'));
const result = await service.generateCpu();
expect(result.flags).toEqual([]);
});
});
describe('generateCpuLoad', () => {
it('should return CPU utilization with all load metrics', async () => {
const result = await service.generateCpuLoad();
expect(result).toEqual({
id: 'info/cpu-load',
percentTotal: 25.5,
cpus: [
{
percentTotal: 30.0,
percentUser: 20.0,
percentSystem: 10.0,
percentNice: 0,
percentIdle: 70.0,
percentIrq: 0,
percentGuest: 0,
percentSteal: 0,
},
{
percentTotal: 21.0,
percentUser: 15.0,
percentSystem: 6.0,
percentNice: 0,
percentIdle: 79.0,
percentIrq: 0,
percentGuest: 0,
percentSteal: 0,
},
],
});
});
it('should include guest and steal metrics when present', async () => {
const { currentLoad } = await import('systeminformation');
vi.mocked(currentLoad).mockResolvedValueOnce({
avgLoad: 2.5,
currentLoad: 25.5,
currentLoadUser: 15.0,
currentLoadSystem: 8.0,
currentLoadNice: 0.5,
currentLoadIdle: 74.5,
currentLoadIrq: 1.0,
currentLoadSteal: 0.2,
currentLoadGuest: 0.3,
rawCurrentLoad: 25500,
rawCurrentLoadUser: 15000,
rawCurrentLoadSystem: 8000,
rawCurrentLoadNice: 500,
rawCurrentLoadIdle: 74500,
rawCurrentLoadIrq: 1000,
rawCurrentLoadSteal: 200,
rawCurrentLoadGuest: 300,
cpus: [
{
load: 30.0,
loadUser: 20.0,
loadSystem: 10.0,
loadNice: 0,
loadIdle: 70.0,
loadIrq: 0,
loadGuest: 2.5,
loadSteal: 1.2,
rawLoad: 30000,
rawLoadUser: 20000,
rawLoadSystem: 10000,
rawLoadNice: 0,
rawLoadIdle: 70000,
rawLoadIrq: 0,
rawLoadGuest: 2500,
rawLoadSteal: 1200,
},
],
});
const result = await service.generateCpuLoad();
expect(result.cpus[0]).toEqual(
expect.objectContaining({
percentGuest: 2.5,
percentSteal: 1.2,
})
);
});
});
});

View File

@@ -37,6 +37,8 @@ export class CpuService {
percentNice: cpu.loadNice,
percentIdle: cpu.loadIdle,
percentIrq: cpu.loadIrq,
percentGuest: cpu.loadGuest || 0,
percentSteal: cpu.loadSteal || 0,
})),
};
}

View File

@@ -32,6 +32,8 @@ describe('MetricsResolver', () => {
loadNice: 0,
loadIdle: 70.0,
loadIrq: 0,
loadGuest: 0,
loadSteal: 0,
},
{
load: 21.0,
@@ -40,6 +42,8 @@ describe('MetricsResolver', () => {
loadNice: 0,
loadIdle: 79.0,
loadIrq: 0,
loadGuest: 0,
loadSteal: 0,
},
],
}),

View File

@@ -0,0 +1,87 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { UserSettingsService } from '@unraid/shared/services/user-settings.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/oidc-config.service.js';
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
import { OidcUrlPatterns } from '@app/unraid-api/graph/resolvers/sso/utils/oidc-url-patterns.util.js';
describe('OidcConfigPersistence', () => {
let service: OidcConfigPersistence;
let mockConfigService: ConfigService;
let mockUserSettingsService: UserSettingsService;
let mockValidationService: OidcValidationService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OidcConfigPersistence,
{
provide: ConfigService,
useValue: {
get: vi.fn(),
set: vi.fn(),
},
},
{
provide: UserSettingsService,
useValue: {
register: vi.fn(),
},
},
{
provide: OidcValidationService,
useValue: {
validateProvider: vi.fn(),
},
},
],
}).compile();
service = module.get<OidcConfigPersistence>(OidcConfigPersistence);
mockConfigService = module.get<ConfigService>(ConfigService);
mockUserSettingsService = module.get<UserSettingsService>(UserSettingsService);
mockValidationService = module.get<OidcValidationService>(OidcValidationService);
// Mock persist method to avoid file system operations
vi.spyOn(service, 'persist').mockResolvedValue(true);
});
describe('URL validation integration', () => {
it('should validate issuer URLs using the shared utility', () => {
// Test that our shared utility correctly validates URLs
// This ensures the pattern we use in the form schema works correctly
const examples = OidcUrlPatterns.getExamples();
// Test valid URLs
examples.valid.forEach((url) => {
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(true);
});
// Test invalid URLs
examples.invalid.forEach((url) => {
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(false);
});
});
it('should validate the pattern constant matches the regex', () => {
// Ensure the pattern string can be compiled into a valid regex
expect(() => new RegExp(OidcUrlPatterns.ISSUER_URL_PATTERN)).not.toThrow();
// Ensure the static regex matches the pattern
const manualRegex = new RegExp(OidcUrlPatterns.ISSUER_URL_PATTERN);
expect(OidcUrlPatterns.ISSUER_URL_REGEX.source).toBe(manualRegex.source);
});
it('should reject the specific URL from the bug report', () => {
// Test the exact scenario that caused the original bug
const problematicUrl = 'https://accounts.google.com/';
const correctUrl = 'https://accounts.google.com';
expect(OidcUrlPatterns.isValidIssuerUrl(problematicUrl)).toBe(false);
expect(OidcUrlPatterns.isValidIssuerUrl(correctUrl)).toBe(true);
});
});
});

View File

@@ -12,6 +12,7 @@ import {
OidcAuthorizationRule,
OidcProvider,
} from '@app/unraid-api/graph/resolvers/sso/models/oidc-provider.model.js';
import { OidcUrlPatterns } from '@app/unraid-api/graph/resolvers/sso/utils/oidc-url-patterns.util.js';
import {
createAccordionLayout,
createLabeledControl,
@@ -194,25 +195,31 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
provider.authorizationRules = rules;
}
// Validate that authorization rules are present and valid for ALL providers
// Skip providers without authorization rules (they will be ignored)
if (!provider.authorizationRules || provider.authorizationRules.length === 0) {
throw new Error(
`Provider "${provider.name}" requires authorization rules. Please configure who can access your server.`
this.logger.warn(
`Provider "${provider.name}" has no authorization rules and will be ignored. Configure authorization rules to enable this provider.`
);
}
// Validate each rule has valid values
for (const rule of provider.authorizationRules) {
if (!rule.claim || !rule.claim.trim()) {
throw new Error(`Provider "${provider.name}": Authorization rule claim cannot be empty`);
}
if (!rule.operator) {
throw new Error(`Provider "${provider.name}": Authorization rule operator is required`);
}
if (!rule.value || rule.value.length === 0 || rule.value.every((v) => !v || !v.trim())) {
throw new Error(
`Provider "${provider.name}": Authorization rule for claim "${rule.claim}" must have at least one non-empty value`
);
// Validate each rule has valid values (only if rules exist)
if (provider.authorizationRules && provider.authorizationRules.length > 0) {
for (const rule of provider.authorizationRules) {
if (!rule.claim || !rule.claim.trim()) {
throw new Error(
`Provider "${provider.name}": Authorization rule claim cannot be empty`
);
}
if (!rule.operator) {
throw new Error(
`Provider "${provider.name}": Authorization rule operator is required`
);
}
if (!rule.value || rule.value.length === 0 || rule.value.every((v) => !v || !v.trim())) {
throw new Error(
`Provider "${provider.name}": Authorization rule for claim "${rule.claim}" must have at least one non-empty value`
);
}
}
}
@@ -370,12 +377,13 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
}),
};
// Validate authorization rules for ALL providers including unraid.net
// Validate authorization rules for providers that have them
for (const provider of processedConfig.providers) {
if (!provider.authorizationRules || provider.authorizationRules.length === 0) {
throw new Error(
`Provider "${provider.name}" requires authorization rules. Please configure who can access your server.`
this.logger.warn(
`Provider "${provider.name}" has no authorization rules and will be ignored. Configure authorization rules to enable this provider.`
);
continue;
}
// Validate each rule has valid values
@@ -565,9 +573,9 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
providersSlice.elements[0].elements.unshift(
createLabeledControl({
scope: '#/properties/sso/properties/defaultAllowedOrigins',
label: 'Allowed Redirect Origins',
label: 'Allowed OIDC Redirect Origins',
description:
'Add trusted origins here when accessing Unraid through custom ports, reverse proxies, or Tailscale. Each origin should include the protocol and optionally a port (e.g., https://unraid.local:8443)',
'Add trusted origins for OIDC redirection. These are URLs that the OIDC provider can redirect to after authentication when accessing Unraid through custom ports, reverse proxies, or Tailscale. Each origin should include the protocol and optionally a port (e.g., https://unraid.local:8443)',
controlOptions: {
format: 'array',
inputType: 'text',
@@ -613,7 +621,22 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
type: 'string',
title: 'Issuer URL',
format: 'uri',
description: 'OIDC issuer URL (e.g., https://accounts.google.com)',
allOf: [
{
pattern: OidcUrlPatterns.ISSUER_URL_PATTERN,
errorMessage:
'Must be a valid HTTP or HTTPS URL without trailing slashes or whitespace',
},
{
not: {
pattern: '\\.well-known',
},
errorMessage:
'Cannot contain /.well-known/ paths. Use the base issuer URL instead (e.g., https://accounts.google.com instead of https://accounts.google.com/.well-known/openid-configuration)',
},
],
description:
'OIDC issuer URL (e.g., https://accounts.google.com). Cannot contain /.well-known/ paths - use the base issuer URL instead of the full discovery endpoint. Must not end with a trailing slash.',
},
authorizationEndpoint: {
anyOf: [

View File

@@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest';
import { OidcUrlPatterns } from '@app/unraid-api/graph/resolvers/sso/utils/oidc-url-patterns.util.js';
describe('OidcUrlPatterns', () => {
describe('ISSUER_URL_PATTERN', () => {
it('should be defined as a string', () => {
expect(typeof OidcUrlPatterns.ISSUER_URL_PATTERN).toBe('string');
expect(OidcUrlPatterns.ISSUER_URL_PATTERN).toBe('^https?://[^/\\s]+(?:/[^/\\s]*)*[^/\\s]$');
});
});
describe('ISSUER_URL_REGEX', () => {
it('should be a RegExp instance', () => {
expect(OidcUrlPatterns.ISSUER_URL_REGEX).toBeInstanceOf(RegExp);
});
it('should match the pattern string', () => {
const regex = new RegExp(OidcUrlPatterns.ISSUER_URL_PATTERN);
expect(OidcUrlPatterns.ISSUER_URL_REGEX.source).toBe(regex.source);
});
});
describe('isValidIssuerUrl', () => {
it('should accept valid URLs without trailing slash', () => {
const validUrls = [
'https://accounts.google.com',
'https://auth.example.com/oidc',
'https://auth.example.com/realms/master',
'http://localhost:8080',
'http://localhost:8080/auth',
'https://login.microsoftonline.com/common/v2.0',
];
validUrls.forEach((url) => {
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(true);
});
});
it('should reject URLs with trailing slashes', () => {
const invalidUrls = [
'https://accounts.google.com/',
'https://auth.example.com/oidc/',
'https://auth.example.com/realms/master/',
'http://localhost:8080/',
'http://localhost:8080/auth/',
'https://login.microsoftonline.com/common/v2.0/',
];
invalidUrls.forEach((url) => {
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(false);
});
});
it('should reject URLs with whitespace', () => {
const invalidUrls = [
'https://accounts.google.com ',
' https://accounts.google.com',
'https://accounts. google.com',
'https://accounts.google.com\t',
'https://accounts.google.com\n',
];
invalidUrls.forEach((url) => {
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(false);
});
});
it('should accept both HTTP and HTTPS protocols', () => {
expect(OidcUrlPatterns.isValidIssuerUrl('https://example.com')).toBe(true);
expect(OidcUrlPatterns.isValidIssuerUrl('http://example.com')).toBe(true);
});
it('should reject other protocols', () => {
expect(OidcUrlPatterns.isValidIssuerUrl('ftp://example.com')).toBe(false);
expect(OidcUrlPatterns.isValidIssuerUrl('ws://example.com')).toBe(false);
expect(OidcUrlPatterns.isValidIssuerUrl('file://example.com')).toBe(false);
});
it('should accept .well-known URLs without trailing slashes', () => {
const wellKnownUrls = [
'https://example.com/.well-known/openid-configuration',
'https://auth.example.com/path/.well-known/openid-configuration',
'https://example.com/.well-known/jwks.json',
'https://keycloak.example.com/realms/master/.well-known/openid-configuration',
];
wellKnownUrls.forEach((url) => {
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(true);
});
});
it('should reject .well-known URLs with trailing slashes', () => {
const invalidWellKnownUrls = [
'https://example.com/.well-known/openid-configuration/',
'https://auth.example.com/path/.well-known/openid-configuration/',
'https://example.com/.well-known/jwks.json/',
'https://keycloak.example.com/realms/master/.well-known/openid-configuration/',
];
invalidWellKnownUrls.forEach((url) => {
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(false);
});
});
it('should handle complex real-world scenarios', () => {
// Google
expect(OidcUrlPatterns.isValidIssuerUrl('https://accounts.google.com')).toBe(true);
expect(OidcUrlPatterns.isValidIssuerUrl('https://accounts.google.com/')).toBe(false);
// Microsoft
expect(
OidcUrlPatterns.isValidIssuerUrl('https://login.microsoftonline.com/tenant-id/v2.0')
).toBe(true);
expect(
OidcUrlPatterns.isValidIssuerUrl('https://login.microsoftonline.com/tenant-id/v2.0/')
).toBe(false);
// Auth0
expect(OidcUrlPatterns.isValidIssuerUrl('https://tenant.auth0.com')).toBe(true);
expect(OidcUrlPatterns.isValidIssuerUrl('https://tenant.auth0.com/')).toBe(false);
// Keycloak
expect(OidcUrlPatterns.isValidIssuerUrl('https://keycloak.example.com/realms/master')).toBe(
true
);
expect(OidcUrlPatterns.isValidIssuerUrl('https://keycloak.example.com/realms/master/')).toBe(
false
);
// AWS Cognito
expect(
OidcUrlPatterns.isValidIssuerUrl(
'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_example'
)
).toBe(true);
expect(
OidcUrlPatterns.isValidIssuerUrl(
'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_example/'
)
).toBe(false);
});
});
describe('getExamples', () => {
it('should return valid and invalid URL examples', () => {
const examples = OidcUrlPatterns.getExamples();
expect(examples).toHaveProperty('valid');
expect(examples).toHaveProperty('invalid');
expect(Array.isArray(examples.valid)).toBe(true);
expect(Array.isArray(examples.invalid)).toBe(true);
expect(examples.valid.length).toBeGreaterThan(0);
expect(examples.invalid.length).toBeGreaterThan(0);
});
it('should have all valid examples pass validation', () => {
const examples = OidcUrlPatterns.getExamples();
examples.valid.forEach((url) => {
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(true);
});
});
it('should have all invalid examples fail validation', () => {
const examples = OidcUrlPatterns.getExamples();
examples.invalid.forEach((url) => {
expect(OidcUrlPatterns.isValidIssuerUrl(url)).toBe(false);
});
});
});
describe('integration with the bug report scenario', () => {
it('should specifically catch the Google trailing slash issue from the bug report', () => {
// The exact scenario from the bug report
const problematicUrl = 'https://accounts.google.com/';
const correctUrl = 'https://accounts.google.com';
expect(OidcUrlPatterns.isValidIssuerUrl(problematicUrl)).toBe(false);
expect(OidcUrlPatterns.isValidIssuerUrl(correctUrl)).toBe(true);
});
it('should prevent the double slash in discovery URL construction', () => {
// Simulate what would happen in discovery URL construction
const issuerWithSlash = 'https://accounts.google.com/';
const issuerWithoutSlash = 'https://accounts.google.com';
// This is what would happen in the discovery process
const discoveryWithSlash = `${issuerWithSlash}/.well-known/openid-configuration`;
const discoveryWithoutSlash = `${issuerWithoutSlash}/.well-known/openid-configuration`;
expect(discoveryWithSlash).toBe(
'https://accounts.google.com//.well-known/openid-configuration'
); // Double slash - bad
expect(discoveryWithoutSlash).toBe(
'https://accounts.google.com/.well-known/openid-configuration'
); // Single slash - good
// Our validation should prevent the first scenario
expect(OidcUrlPatterns.isValidIssuerUrl(issuerWithSlash)).toBe(false);
expect(OidcUrlPatterns.isValidIssuerUrl(issuerWithoutSlash)).toBe(true);
});
});
});

View File

@@ -0,0 +1,59 @@
/**
* Utility for OIDC URL validation patterns
*/
export class OidcUrlPatterns {
/**
* Regex pattern for validating OIDC issuer URLs
* - Allows HTTP and HTTPS protocols
* - Prevents trailing slashes
* - Prevents whitespace
* - Allows paths but not ending with slash
*/
static readonly ISSUER_URL_PATTERN = '^https?://[^/\\s]+(?:/[^/\\s]*)*[^/\\s]$';
/**
* Compiled regex for issuer URL validation
*/
static readonly ISSUER_URL_REGEX = new RegExp(OidcUrlPatterns.ISSUER_URL_PATTERN);
/**
* Validate an issuer URL against the pattern
* @param url The URL to validate
* @returns True if the URL is valid, false otherwise
*/
static isValidIssuerUrl(url: string): boolean {
return this.ISSUER_URL_REGEX.test(url);
}
/**
* Get examples of valid and invalid issuer URLs for documentation/testing
*/
static getExamples() {
return {
valid: [
// Standard issuer URLs (most common)
'https://accounts.google.com',
'https://auth.example.com/oidc',
'https://auth.example.com/realms/master',
'http://localhost:8080',
'http://localhost:8080/auth',
'https://login.microsoftonline.com/common/v2.0',
'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_example',
// Well-known URLs are valid at the URL pattern level (schema-level validation handles rejection)
'https://example.com/.well-known/openid-configuration',
'https://auth.example.com/path/.well-known/openid-configuration',
'https://example.com/.well-known/jwks.json',
],
invalid: [
'https://accounts.google.com/', // Trailing slash
'https://auth.example.com/oidc/', // Trailing slash
'https://auth.example.com/realms/master/', // Trailing slash
'http://localhost:8080/', // Trailing slash
'https://accounts.google.com ', // Trailing whitespace
' https://accounts.google.com', // Leading whitespace
'https://accounts. google.com', // Internal whitespace
'ftp://example.com', // Invalid protocol
],
};
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "unraid-monorepo",
"private": true,
"version": "4.18.0",
"version": "4.18.2",
"scripts": {
"build": "pnpm -r build",
"build:watch": " pnpm -r --parallel build:watch",

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/connect-plugin",
"version": "4.18.0",
"version": "4.18.2",
"private": true,
"dependencies": {
"commander": "14.0.0",

View File

@@ -16,7 +16,7 @@
]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&plugin_url;"
launch="&launch;" min="6.9.0-rc1" icon="globe">
launch="&launch;" min="6.12.15" icon="globe">
<CHANGES>
##a long time ago in a galaxy far far away
@@ -61,19 +61,19 @@ exit 0
$version = @parse_ini_file('/etc/unraid-version', true)['version'];
// Check if this is a supported version
// - Must be 6.12.0 or higher
// - Must not be a 6.12.0 beta/rc version
$is_stable_6_12_or_higher = version_compare($version, '6.12.0', '>=') && !preg_match('/^6\\.12\\.0-/', $version);
// - Must be 6.12.15 or higher
// - Must not be a 6.12.15 beta/rc version
$is_stable_6_12_or_higher = version_compare($version, '6.12.15', '>=') && !preg_match('/^6\\.12\\.0-/', $version);
if ($is_stable_6_12_or_higher) {
echo "Running on supported version {$version}\n";
exit(0);
}
echo "Warning: Unsupported Unraid version {$version}. This plugin requires Unraid 6.12.0 or higher.\n";
echo "The plugin may not function correctly on this system.\n";
echo "Warning: Unsupported Unraid version {$version}. This plugin requires Unraid 6.12.15 or higher.\n";
echo "The plugin will not function correctly on this system.\n";
exit(0);
exit(1);
]]>
</INLINE>
</FILE>

View File

@@ -1,6 +0,0 @@
Menu="ManagementAccess:160"
Title="API Config Download"
Icon="icon-download"
Tag="download"
---
<unraid-config-download />

12
pnpm-lock.yaml generated
View File

@@ -864,6 +864,9 @@ importers:
'@vueuse/core':
specifier: 13.8.0
version: 13.8.0(vue@3.5.20(typescript@5.9.2))
ajv-errors:
specifier: ^3.0.0
version: 3.0.0(ajv@8.17.1)
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@@ -6062,6 +6065,11 @@ packages:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
engines: {node: '>=8'}
ajv-errors@3.0.0:
resolution: {integrity: sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==}
peerDependencies:
ajv: ^8.0.1
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -19100,6 +19108,10 @@ snapshots:
clean-stack: 2.2.0
indent-string: 4.0.0
ajv-errors@3.0.0(ajv@8.17.1):
dependencies:
ajv: 8.17.1
ajv-formats@2.1.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1

View File

@@ -18,6 +18,7 @@
[![Issues][issues-shield]][issues-url]
[![MIT License][license-shield]][license-url]
[![LinkedIn][linkedin-shield]][linkedin-url]
[![Codecov][codecov-shield]][codecov-url]
</div>
<!-- PROJECT LOGO -->
@@ -283,6 +284,8 @@ Project Link: [https://github.com/unraid/api](https://github.com/unraid/api)
[license-url]: https://github.com/unraid/api/blob/main/LICENSE.txt
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555
[linkedin-url]: https://www.linkedin.com/company/unraid
[codecov-shield]: https://img.shields.io/codecov/c/github/unraid/api?style=for-the-badge
[codecov-url]: https://codecov.io/gh/unraid/api
[Nuxt.js]: https://img.shields.io/badge/Nuxt-002E3B?style=for-the-badge&logo=nuxtdotjs&logoColor=#00DC82
[Node.js]: https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white
[PHP]: https://img.shields.io/badge/php-%23777BB4.svg?style=for-the-badge&logo=php&logoColor=white

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/ui",
"version": "4.18.0",
"version": "4.18.2",
"private": true,
"license": "GPL-2.0-or-later",
"type": "module",
@@ -42,9 +42,9 @@
"deploy:storybook:staging": "pnpm build-storybook && wrangler deploy --env staging"
},
"peerDependencies": {
"ajv": "8.17.1",
"tailwindcss": "4.1.12",
"vue": "3.5.20",
"ajv": "8.17.1"
"vue": "3.5.20"
},
"dependencies": {
"@headlessui/vue": "1.7.23",
@@ -55,6 +55,7 @@
"@jsonforms/vue-vanilla": "3.6.0",
"@tailwindcss/cli": "4.1.12",
"@vueuse/core": "13.8.0",
"ajv-errors": "^3.0.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"dompurify": "3.2.6",

View File

@@ -153,8 +153,8 @@ const addItem = () => {
};
const removeItem = (index: number) => {
const newItems = [...items.value];
newItems.splice(index, 1);
// Create a completely new array by filtering out the item
const newItems = items.value.filter((_, i) => i !== index);
items.value = newItems;
};

View File

@@ -24,8 +24,8 @@ const addItem = () => {
};
const removeItem = (index: number) => {
const newItems = [...items.value];
newItems.splice(index, 1);
// Create a completely new array by filtering out the item
const newItems = items.value.filter((_, i) => i !== index);
items.value = newItems;
};

View File

@@ -1,5 +1,6 @@
import { createAjv } from '@jsonforms/core';
import type Ajv from 'ajv';
import addErrors from 'ajv-errors';
export interface JsonFormsConfig {
/**
@@ -20,10 +21,15 @@ export interface JsonFormsConfig {
* This ensures all JSONForms instances have proper validation and visibility rule support
*/
export function createJsonFormsAjv(): Ajv {
return createAjv({
const ajv = createAjv({
allErrors: true,
strict: false,
});
// Add support for custom error messages
addErrors(ajv);
return ajv;
}
/**

View File

@@ -52,7 +52,6 @@ const { modalVisible, editingKey, isAuthorizationMode, authorizationData, create
// This will be transformed into CreateApiKeyInput or UpdateApiKeyInput
interface FormData extends Partial<CreateApiKeyInput> {
keyName?: string; // Used in authorization mode
authorizationType?: 'roles' | 'groups' | 'custom';
permissionGroups?: string[];
permissionPresets?: string; // For the preset dropdown
customPermissions?: Array<{
@@ -74,7 +73,6 @@ const formSchema = ref<ApiKeyFormSettings | null>(null);
const formData = ref<FormData>({
customPermissions: [],
roles: [],
authorizationType: 'roles',
} as FormData);
const formValid = ref(false);
@@ -83,10 +81,14 @@ const { copyWithNotification, copied } = useClipboardWithToast();
// Computed property to transform formData permissions for the EffectivePermissions component
const formDataPermissions = computed(() => {
if (!formData.value.customPermissions) return [];
// Explicitly depend on the array length to ensure reactivity when going to/from empty
const permissions = formData.value.customPermissions;
const permissionCount = permissions?.length ?? 0;
if (!permissions || permissionCount === 0) return [];
// Flatten the resources array into individual permission entries
return formData.value.customPermissions.flatMap((perm) =>
return permissions.flatMap((perm) =>
perm.resources.map((resource) => ({
resource,
actions: perm.actions, // Already string[] which can be AuthAction values
@@ -141,6 +143,7 @@ const loadFormSchema = () => {
// For new keys, initialize with empty data
formData.value = {
customPermissions: [],
roles: [],
};
// Set formValid to true initially for new keys
// JsonForms will update this if there are validation errors
@@ -250,7 +253,6 @@ const populateFormFromExistingKey = async () => {
formData.value = {
name: fragmentKey.name,
description: fragmentKey.description || '',
authorizationType: fragmentKey.roles.length > 0 ? 'roles' : 'custom',
roles: [...fragmentKey.roles],
customPermissions,
};
@@ -303,7 +305,10 @@ const transformFormDataForApi = (): CreateApiKeyInput => {
const close = () => {
apiKeyStore.hideModal();
formData.value = {} as FormData; // Reset to empty object
formData.value = {
customPermissions: [],
roles: [],
} as FormData;
};
// Handle form submission
@@ -356,7 +361,10 @@ async function upsertKey() {
}
apiKeyStore.hideModal();
formData.value = {} as FormData; // Reset to empty object
formData.value = {
customPermissions: [],
roles: [],
} as FormData;
} catch (error) {
console.error('Error in upsertKey:', error);
} finally {

View File

@@ -34,6 +34,7 @@ const state = reactive({
loadedContentChunks: [] as { content: string; startLine: number }[],
currentStartLine: undefined as number | undefined,
isLoadingMore: false,
isRefreshing: false,
isAtTop: false,
canLoadMore: false,
initialLoadComplete: false,
@@ -104,11 +105,25 @@ watch(
const effectiveStartLine = startLine || 1;
if (state.isLoadingMore) {
// Loading more historical content - prepend to existing chunks
state.loadedContentChunks.unshift({ content, startLine: effectiveStartLine });
state.isLoadingMore = false;
nextTick(() => (state.canLoadMore = true));
} else if (state.isRefreshing) {
// Refreshing - replace all content and reset state
state.loadedContentChunks = [{ content, startLine: effectiveStartLine }];
state.isRefreshing = false;
state.currentStartLine = undefined;
state.isAtTop = false;
state.initialLoadComplete = true;
nextTick(() => {
forceScrollToBottom();
setTimeout(() => (state.canLoadMore = true), 300);
});
} else {
// Initial load - replace all content
state.loadedContentChunks = [{ content, startLine: effectiveStartLine }];
nextTick(() => {
@@ -235,16 +250,6 @@ const downloadLogFile = async () => {
}
};
// Clear all state to initial values
const clearState = () => {
state.loadedContentChunks = [];
state.currentStartLine = undefined;
state.isAtTop = false;
state.canLoadMore = false;
state.initialLoadComplete = false;
state.isLoadingMore = false;
};
// Helper function to start log subscription
const startLogSubscription = () => {
if (!props.logFilePath) return;
@@ -297,10 +302,12 @@ const startLogSubscription = () => {
}
};
// Refresh logs
// Refresh logs with full reset
const refreshLogContent = async () => {
// Clear the state
clearState();
// Set refresh flag to indicate we're refreshing
state.isRefreshing = true;
state.isLoadingMore = false;
state.canLoadMore = false;
// Refetch with explicit variables to ensure we get the latest logs
await refetchLogContent({
@@ -310,13 +317,7 @@ const refreshLogContent = async () => {
});
// Restart the subscription with the same variables used for refetch
// Note: subscribeToMore in Vue Apollo doesn't return an unsubscribe function
// The previous subscription is automatically replaced when calling subscribeToMore again
startLogSubscription();
nextTick(() => {
forceScrollToBottom();
});
};
watch(() => props.logFilePath, refreshLogContent);

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/web",
"version": "4.18.0",
"version": "4.18.2",
"private": true,
"license": "GPL-2.0-or-later",
"scripts": {

View File

@@ -22,6 +22,9 @@ export default defineConfig({
include: ['__test__/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts}'],
testTimeout: 5000,
hookTimeout: 5000,
coverage: {
reporter: ['text', 'json', 'html'],
},
},
resolve: {
alias: {