Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions e2e/fixtures/admin-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,20 @@ export const ADMIN_PASSWORD =
* and userData in-memory; a synthetic localStorage seed does not work
* because no code path hydrates userData from storage at bootstrap.
*/
export async function loginAsAdmin(page: Page) {
export async function loginAsAdmin(
page: Page,
adminEmail = ADMIN_EMAIL,
adminPassword = ADMIN_PASSWORD
) {
await page.context().clearCookies();
await page.goto('/dreamfactory/dist/#/auth/login');
// Labels come from the userManagement i18n bundle — "Enter Email" /
// "Enter Password" in English. Use type-based selectors to stay
// language-agnostic.
const email = page.locator('input').first();
await expect(email).toBeVisible({ timeout: 15_000 });
await email.fill(ADMIN_EMAIL);
await page.locator('input').nth(1).fill(ADMIN_PASSWORD);
await email.fill(adminEmail);
await page.locator('input').nth(1).fill(adminPassword);

const loginReq = page.waitForResponse(
r =>
Expand Down
76 changes: 76 additions & 0 deletions e2e/fixtures/df-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,82 @@ export class DfApi {
}
}

/** Effective log level as system/environment reports it. */
async getEnvironmentLogLevel(): Promise<string> {
const resp = await this.ctx.get('/api/v2/system/environment', {
headers: this.headers(),
});
expect(resp.ok()).toBe(true);
return (await resp.json()).platform.log_level;
}

/** Remove a server-setting override (back to the .env value). */
async resetServerSetting(name: string): Promise<void> {
const resp = await this.ctx.delete(
`/api/v2/system/server_setting/${name}`,
{ headers: this.headers() }
);
expect(resp.ok(), `DELETE server_setting/${name} -> ${resp.status()}`).toBe(
true
);
}

/** Create a restricted admin limited to the given tabs; returns the admin id. */
async createRestrictedAdmin(
email: string,
password: string,
tabs: string[]
): Promise<number> {
const resp = await this.ctx.post('/api/v2/system/admin', {
headers: this.headers(),
data: {
resource: [
{
email,
password,
name: 'E2E Restricted',
first_name: 'E2E',
last_name: 'Restricted',
is_active: true,
is_restricted_admin: true,
access_by_tabs: tabs,
},
],
},
});
expect(
resp.ok(),
`POST admin -> HTTP ${resp.status()}: ${await resp.text()}`
).toBe(true);
return (await resp.json()).resource[0].id;
}

/** API key of the admin UI app; a role only applies to requests that carry it. */
async getAdminAppApiKey(): Promise<string> {
const resp = await this.ctx.get(
'/api/v2/system/app?filter=name%3Dadmin&fields=api_key',
{ headers: this.headers() }
);
expect(resp.ok()).toBe(true);
return (await resp.json()).resource[0].api_key;
}

/** Delete an admin and the role created for it (restricted admins get "<email>'s role"). */
async deleteRestrictedAdmin(id: number, email: string): Promise<void> {
await this.ctx.delete(`/api/v2/system/admin/${id}`, {
headers: this.headers(),
});
const roles = await this.ctx.get(
`/api/v2/system/role?filter=${encodeURIComponent(`name=${email}'s role`)}`,
{ headers: this.headers() }
);
for (const r of (await roles.json())?.resource ?? []) {
await this.ctx.delete(`/api/v2/system/role/${r.id}`, {
headers: this.headers(),
});
}
}

/** Delete every service whose name starts with the e2e prefix. */
async deleteByNamePrefix(prefix: string = E2E_SERVICE_PREFIX): Promise<void> {
const rows = await this.listServices();
Expand Down
119 changes: 119 additions & 0 deletions e2e/server-settings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { APIRequestContext, expect, request, test } from '@playwright/test';
import { loginAsAdmin, waitForAppReady } from './fixtures/admin-login';
import { DfApi } from './fixtures/df-api';

/**
* Server settings (#552): override the log level from the UI, see the
* override badge and the new System Info value, then reset to .env.
* Non-destructive: the override is always removed in afterAll.
*/
test.describe('Server settings', () => {
let ctx: APIRequestContext;
let api: DfApi;
let original: string;

test.beforeAll(async ({ baseURL }) => {
ctx = await request.newContext({ baseURL });
api = await DfApi.login(ctx);
original = await api.getEnvironmentLogLevel();
});

test.afterAll(async () => {
await api.resetServerSetting('log_level');
await ctx.dispose();
});

test('change the log level, then reset it to .env', async ({ page }) => {
const target = original === 'error' ? 'debug' : 'error';

await loginAsAdmin(page);
await waitForAppReady(page);
// Hash-only change: no reload, so the in-memory login survives.
await page.goto('/dreamfactory/dist/#/system-settings/config/server-settings');

// sidenav label and page header must be translated, not raw i18n keys
await expect(page.locator('body')).toContainText('Server Settings');
await expect(page.locator('body')).not.toContainText(/nav\.system-settings/);

const row = page.getByTestId('server-setting-log_level');
await expect(row.getByTestId('server-setting-source')).toHaveText(/\.env/);
await expect(row.getByTestId('server-setting-reset')).toBeDisabled();

await row.getByTestId('server-setting-select').click();
await page.getByRole('option', { name: target, exact: true }).click();
await row.getByTestId('server-setting-save').click();

await expect(row.getByTestId('server-setting-source')).toHaveText(
/override/
);
await expect(page.getByTestId('server-settings-view-info')).toBeVisible();
expect(await api.getEnvironmentLogLevel()).toBe(target);

await row.getByTestId('server-setting-reset').click();
await page.getByTestId('confirm-dialog-confirm').click();

await expect(row.getByTestId('server-setting-source')).toHaveText(/\.env/);
expect(await api.getEnvironmentLogLevel()).toBe(original);
});

test.describe('restricted admins', () => {
const password = 'E2e-restricted-pass1!';
const created: { id: number; email: string }[] = [];

const makeAdmin = async (tabs: string[]) => {
const email = `e2e-ss-${tabs[0]}-${Date.now()}@example.com`;
created.push({
id: await api.createRestrictedAdmin(email, password, tabs),
email,
});
return email;
};

test.afterAll(async () => {
for (const a of created) {
await api.deleteRestrictedAdmin(a.id, a.email);
}
});

test('without the Config tab: API 403, no nav entry, deep link lands Home', async ({
page,
}) => {
const email = await makeAdmin(['apps']);

const session = await ctx.post('/api/v2/system/admin/session', {
data: { email, password },
});
expect(session.ok()).toBe(true);
const denied = await ctx.get('/api/v2/system/server_setting', {
headers: {
'X-DreamFactory-API-Key': await api.getAdminAppApiKey(),
'X-DreamFactory-Session-Token': (await session.json())
.session_token,
},
});
expect(denied.status()).toBe(403);

await loginAsAdmin(page, email, password);
await waitForAppReady(page);
await page.goto(
'/dreamfactory/dist/#/system-settings/config/server-settings'
);
await expect(page).toHaveURL(/#\/home/);
await expect(page.getByTestId('server-setting-log_level')).toHaveCount(0);
await expect(
page.getByRole('link', { name: 'Server Settings' })
).toHaveCount(0);
});

test('with the Config tab: opens the page', async ({ page }) => {
const email = await makeAdmin(['config']);

await loginAsAdmin(page, email, password);
await waitForAppReady(page);
await page.goto(
'/dreamfactory/dist/#/system-settings/config/server-settings'
);
await expect(page.getByTestId('server-setting-log_level')).toBeVisible();
});
});
});
6 changes: 6 additions & 0 deletions jest.config.ci.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,11 @@ module.exports = {
'<rootDir>/src/app/shared/components/df-secret/df-secret.component.spec.ts',
// 2026-10-05 searchable pickers (#534): dfSearch filter + componentTypes.
'<rootDir>/src/app/shared/components/df-select-search/df-select-search.spec.ts',
// Runtime server settings (#552): service, access rule + guard, page, nav link.
'<rootDir>/src/app/shared/utilities/server-settings-access.spec.ts',
'<rootDir>/src/app/shared/services/df-server-settings.service.spec.ts',
'<rootDir>/src/app/shared/guards/admin.guard.spec.ts',
'<rootDir>/src/app/adf-config/df-server-settings/df-server-settings.component.spec.ts',
'<rootDir>/src/app/adf-config/df-system-info/system-info-link.spec.ts',
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<div class="server-settings-container">
<p>{{ 'serverSettings.description' | transloco }}</p>
<p class="hint">{{ 'serverSettings.hint' | transloco }}</p>

<p *ngIf="error" class="error" role="alert" data-testid="server-settings-error">
{{ error | transloco }}
</p>
<p *ngIf="saved" class="saved" role="status" data-testid="server-settings-saved">
{{ 'serverSettings.saved' | transloco }}
<a [routerLink]="systemInfoLink" data-testid="server-settings-view-info">{{
'serverSettings.viewSystemInfo' | transloco
}}</a>
</p>

<p *ngIf="loading">{{ 'serverSettings.loading' | transloco }}</p>
<p *ngIf="!loading && !rows.length && !error">
{{ 'serverSettings.empty' | transloco }}
</p>

<div class="settings-list">
<div
*ngFor="let row of rows"
class="setting-row"
[attr.data-testid]="'server-setting-' + row.setting.name">
<div class="setting-head">
<strong>{{ row.setting.label }}</strong>
<span
class="badge"
[class.override]="row.setting.source === 'override'"
data-testid="server-setting-source">
{{
(row.setting.source === 'override'
? 'serverSettings.sourceOverride'
: 'serverSettings.sourceEnv'
) | transloco
}}
</span>
</div>
<div class="setting-body">
<mat-select
[(ngModel)]="row.draft"
[attr.aria-label]="row.setting.label"
data-testid="server-setting-select">
<mat-option *ngFor="let v of row.setting.allowedValues" [value]="v">{{
v
}}</mat-option>
</mat-select>
<button
mat-flat-button
class="save-btn"
color="primary"
[disabled]="row.draft === row.setting.value"
(click)="save(row)"
data-testid="server-setting-save">
{{ 'serverSettings.save' | transloco }}
</button>
<button
mat-stroked-button
[disabled]="!canReset(row)"
(click)="reset(row)"
data-testid="server-setting-reset">
{{ 'serverSettings.reset' | transloco }}
</button>
</div>
<small>{{
'serverSettings.envValue' | transloco: { value: row.setting.envValue }
}}</small>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
.server-settings-container {
> p {
max-width: 720px;
margin: 0 0 12px;
color: var(--df-text-2);
font-size: 1.4rem;
line-height: 1.5;
}

.error {
color: var(--df-danger, #b3261e);
}

.settings-list {
max-height: 60vh;
overflow-y: auto;
}

.setting-row {
max-width: 720px;
margin-bottom: 16px;
padding: 16px;
border: 1px solid var(--df-border, rgba(128, 128, 128, 0.3));
border-radius: 8px;
}

.setting-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}

.badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 1.2rem;
border: 1px solid var(--df-border, rgba(128, 128, 128, 0.4));

&.override {
font-weight: 600;
}
}

.setting-body {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;

mat-select {
width: 200px;
}
}
}
Loading
Loading