Merge branch 'v17/dev'

This commit is contained in:
Andy Butland
2026-05-20 10:32:08 +02:00
32 changed files with 1429 additions and 147 deletions
@@ -8,67 +8,38 @@ using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
/// <summary>
/// Controller for setting the redirect URL tracking status.
/// Controller for setting the redirect URL tracking status. Retained for backwards compatibility only;
/// the endpoint no longer modifies any configuration.
/// </summary>
[ApiVersion("1.0")]
[Obsolete("This controller is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public class SetStatusRedirectUrlManagementController : RedirectUrlManagementControllerBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IConfigManipulator _configManipulator;
/// <summary>
/// Initializes a new instance of the <see cref="SetStatusRedirectUrlManagementController"/> class.
/// </summary>
/// <param name="backOfficeSecurityAccessor">The back office security accessor.</param>
/// <param name="configManipulator">The configuration manipulator.</param>
/// <param name="backOfficeSecurityAccessor">Ignored. Retained for binary compatibility.</param>
/// <param name="configManipulator">Ignored. Retained for binary compatibility.</param>
public SetStatusRedirectUrlManagementController(
#pragma warning disable IDE0060 // Remove unused parameter
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IConfigManipulator configManipulator)
#pragma warning restore IDE0060 // Remove unused parameter
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_configManipulator = configManipulator;
}
// TODO: Consider if we should even allow this, or only allow using the appsettings
// We generally don't want to edit the appsettings from our code.
// But maybe there is a valid use case for doing it on the fly.
/// <summary>
/// Sets the redirect URL tracking status.
/// Deprecated. Returns an OK response without modifying any configuration. To toggle redirect URL tracking,
/// set the <c>Umbraco:CMS:WebRouting:DisableRedirectUrlTracking</c> configuration key instead.
/// </summary>
/// <param name="cancellationToken">The cancellation token for the HTTP request.</param>
/// <param name="status">The redirect status to set.</param>
/// <returns>An OK result if successful.</returns>
/// <param name="status">The redirect status (ignored).</param>
/// <returns>An OK result.</returns>
[HttpPost("status")]
[EndpointSummary("Sets the redirect URL tracking status.")]
[EndpointDescription("Updates the redirect URL tracking configuration according to the provided status.")]
[EndpointSummary("Deprecated. No longer changes the redirect URL tracking status.")]
[EndpointDescription("This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.")]
[MapToApiVersion("1.0")]
public async Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
{
// TODO: uncomment this when auth is implemented.
// var userIsAdmin = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin();
// if (userIsAdmin is null or false)
// {
// return Unauthorized();
// }
var enable = status switch
{
RedirectStatus.Enabled => true,
RedirectStatus.Disabled => false,
_ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown redirect status")
};
// For now I'm not gonna change this to limit breaking, but it's weird to have a "disabled" switch,
// since you're essentially negating the boolean from the get go,
// it's much easier to reason with enabled = false == disabled.
await _configManipulator.SaveDisableRedirectUrlTrackingAsync(!enable);
// Taken from the existing implementation in RedirectUrlManagementController
// TODO this is ridiculous, but we need to ensure the configuration is reloaded, before this request is ended.
// otherwise we can read the old value in GetEnableState.
// The value is equal to JsonConfigurationSource.ReloadDelay
Thread.Sleep(250);
return Ok();
}
[Obsolete("This endpoint is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
=> Task.FromResult<IActionResult>(Ok());
}
+2 -2
View File
@@ -28924,8 +28924,8 @@
"tags": [
"Redirect Management"
],
"summary": "Sets the redirect URL tracking status.",
"description": "Updates the redirect URL tracking configuration according to the provided status.",
"summary": "Deprecated. No longer changes the redirect URL tracking status.",
"description": "This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.",
"operationId": "PostRedirectManagementStatus",
"parameters": [
{
@@ -36,6 +36,7 @@ public interface IConfigManipulator
/// </summary>
/// <param name="disable">The value to save.</param>
/// <returns></returns>
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
Task SaveDisableRedirectUrlTrackingAsync(bool disable);
/// <summary>
@@ -104,6 +104,7 @@ internal sealed class JsonConfigManipulator : IConfigManipulator
}
/// <inheritdoc />
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public async Task SaveDisableRedirectUrlTrackingAsync(bool disable)
=> await CreateOrUpdateConfigValueAsync(DisableRedirectUrlTrackingPath, disable);
@@ -47,30 +47,21 @@ public class LogViewerRepository : LogViewerRepositoryBase
var filesForCurrentDay = Directory.GetFiles(_loggingConfiguration.LogDirectory, filesToFind);
// Foreach file we find - open it
// Foreach file we find - open it. Any failure reading a single file (open error,
// unrecoverable parse error, etc.) should not prevent the remaining files for the
// day or date range from being read.
foreach (var filePath in filesForCurrentDay)
{
// Open log file & add contents to the log collection
// Which we then use LINQ to page over
using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
try
{
using (var stream = new StreamReader(fs))
{
var reader = new LogEventReader(stream);
while (TryRead(reader, out LogEvent? evt))
{
// We may get a null if log line is malformed
if (evt == null)
{
continue;
}
if (logFilter.TakeLogEvent(evt))
{
logs.Add(evt);
}
}
}
ReadLogFile(filePath, logFilter, logs);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Skipped log file {FilePath} after a file-level error; the file may be inaccessible or unreadable.",
filePath);
}
}
}
@@ -88,6 +79,63 @@ public class LogViewerRepository : LogViewerRepositoryBase
}).ToArray();
}
private void ReadLogFile(string filePath, ILogFilter logFilter, List<LogEvent> logs)
{
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var stream = new StreamReader(fs);
var reader = new LogEventReader(stream);
var errorCount = 0;
Exception? firstError = null;
while (true)
{
LogEvent? evt;
try
{
if (!reader.TryRead(out evt))
{
break;
}
}
catch (Exception ex) when (ex is Newtonsoft.Json.JsonException or InvalidDataException)
{
// Serilog.Formatting.Compact.Reader uses Newtonsoft.Json internally and surfaces
// its exceptions (Umbraco's own serialization is on System.Text.Json, but that
// doesn't apply here — we have to catch what the reader actually throws).
// JsonException covers parse failures (e.g. an unterminated string in a truncated
// entry); InvalidDataException covers structurally-valid JSON that isn't a valid
// Serilog Compact event. Either way the offending line has been consumed from the
// underlying StreamReader and the next TryRead call advances. Anything else
// (IOException, decoder failures, etc.) is propagated to the file-level catch in
// GetLogs so we don't risk a tight loop or silently swallow a more serious failure.
errorCount++;
firstError ??= ex;
continue;
}
// LogEventReader may return true with a null event for a benign skip.
if (evt is null)
{
continue;
}
if (logFilter.TakeLogEvent(evt))
{
logs.Add(evt);
}
}
if (errorCount > 0)
{
_logger.LogWarning(
firstError,
"Encountered {ErrorCount} unreadable line(s) while reading log file {FilePath}. The file may contain partially-written or corrupt entries; affected lines were skipped.",
errorCount,
filePath);
}
}
private IReadOnlyDictionary<string, string?> MapLogMessageProperties(IReadOnlyDictionary<string, LogEventPropertyValue>? properties)
{
var result = new Dictionary<string, string?>();
@@ -121,21 +169,4 @@ public class LogViewerRepository : LogViewerRepositoryBase
}
private static string GetSearchPattern(DateTime day) => $"*{day:yyyyMMdd}*.json";
private bool TryRead(LogEventReader reader, out LogEvent? evt)
{
try
{
return reader.TryRead(out evt);
}
catch (Exception ex)
{
// As we are reading/streaming one line at a time in the JSON file
// Thus we can not report the line number, as it will always be 1
_logger.LogError(ex, "Unable to parse a line in the JSON log file");
evt = null;
return true;
}
}
}
@@ -2026,6 +2026,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'تعطيل تتبع URL',
enableUrlTracker: 'تمكين تتبع URL',
urlTrackerEnabled: 'ممكّن',
urlTrackerDisabled: 'معطّل',
culture: 'الثقافة',
originalUrl: 'URL الأصلي',
redirectedTo: 'تم إعادة التوجيه إلى',
@@ -1895,6 +1895,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'Onemogući URL tragač',
enableUrlTracker: 'Omogući URL tragač',
urlTrackerEnabled: 'Omogućen',
urlTrackerDisabled: 'Onemogućen',
originalUrl: 'Originalni URL',
redirectedTo: 'Preusmjeri na',
redirectUrlManagement: 'Preusmjeravanje URL-ova',
@@ -1731,6 +1731,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'Zakázat sledování URL',
enableUrlTracker: 'Povolit sledování URL',
urlTrackerEnabled: 'Povoleno',
urlTrackerDisabled: 'Zakázáno',
culture: 'Jazyk',
originalUrl: 'Originální URL',
redirectedTo: 'Přesměrováno na',
@@ -2075,6 +2075,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'Analluogi olinydd URL',
enableUrlTracker: 'Galluogi olinydd URL',
urlTrackerEnabled: "Wedi'i alluogi",
urlTrackerDisabled: "Wedi'i analluogi",
culture: 'Diwylliant',
originalUrl: 'URL gwreiddiol',
redirectedTo: 'Ailgyfeirwyd I',
@@ -2295,6 +2295,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'Slå URL tracker fra',
enableUrlTracker: 'Slå URL tracker til',
urlTrackerEnabled: 'Aktiveret',
urlTrackerDisabled: 'Deaktiveret',
culture: 'Kultur',
originalUrl: 'Original URL',
redirectedTo: 'Viderestillet til',
@@ -1943,6 +1943,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'URL-Änderungsaufzeichnung abschalten',
enableUrlTracker: 'URL-Änderungsaufzeichnung einschalten',
urlTrackerEnabled: 'Aktiviert',
urlTrackerDisabled: 'Deaktiviert',
culture: 'Kultur',
originalUrl: 'Original URL',
redirectedTo: 'Weiterleiten zu',
@@ -2484,7 +2484,13 @@ export default {
},
redirectUrls: {
disableUrlTracker: 'Disable URL tracker',
disableUrlTrackerInstruction:
'Redirect URL tracking is configured through application settings. To disable tracking, set the following configuration key to true:',
enableUrlTracker: 'Enable URL tracker',
enableUrlTrackerInstruction:
'Redirect URL tracking is configured through application settings. To enable tracking, set the following configuration key to false:',
urlTrackerEnabled: 'Enabled',
urlTrackerDisabled: 'Disabled',
originalUrl: 'Original URL',
redirectedTo: 'Redirected To',
redirectUrlManagement: 'Redirect URL Management',
@@ -1432,6 +1432,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'Desactivar URL tracker',
enableUrlTracker: 'Activar URL tracker',
urlTrackerEnabled: 'Activado',
urlTrackerDisabled: 'Desactivado',
originalUrl: 'URL Original',
redirectedTo: 'Redirigido a To',
noRedirects: 'No se ha creado ninguna redirección',
@@ -1784,6 +1784,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'Désactiver URL tracker',
enableUrlTracker: 'Activer URL tracker',
urlTrackerEnabled: 'Activé',
urlTrackerDisabled: 'Désactivé',
culture: 'Culture',
originalUrl: 'URL original',
redirectedTo: 'Redirigé Vers',
@@ -1955,6 +1955,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'Onemogući URL praćenje',
enableUrlTracker: 'Omogući URL praćenje',
urlTrackerEnabled: 'Omogućeno',
urlTrackerDisabled: 'Onemogućeno',
originalUrl: 'Originalni URL',
redirectedTo: 'Preusmjerno na',
redirectUrlManagement: 'Preusmjeravanje URL-ova',
@@ -1993,6 +1993,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'Disabilita tracciamento degli URL',
enableUrlTracker: 'Abilita tracciamento degli URL',
urlTrackerEnabled: 'Abilitato',
urlTrackerDisabled: 'Disabilitato',
culture: 'Cultura',
originalUrl: 'URL originale',
redirectedTo: 'Reindirizzato a',
@@ -1877,6 +1877,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'URL tracker uitschakelen',
enableUrlTracker: 'URL tracker inschakelen',
urlTrackerEnabled: 'Ingeschakeld',
urlTrackerDisabled: 'Uitgeschakeld',
culture: 'Cultuur',
originalUrl: 'Originele URL',
redirectedTo: 'Doorgestuurd naar',
@@ -1277,6 +1277,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'Wyłącz śledzenie URL',
enableUrlTracker: 'Włącz śledzenie URL',
urlTrackerEnabled: 'Włączone',
urlTrackerDisabled: 'Wyłączone',
originalUrl: 'Oryginalny URL',
redirectedTo: 'Przekierowane do',
noRedirects: 'Nie stworzono żadnych przekierowań',
@@ -2287,6 +2287,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'Desativar Monitorizador de URLs',
enableUrlTracker: 'Ativar Monitorizador de URLs',
urlTrackerEnabled: 'Ativado',
urlTrackerDisabled: 'Desativado',
originalUrl: 'URL Original',
redirectedTo: 'Redirecionado Para',
redirectUrlManagement: 'Gestão de URL de Redirecionamento',
@@ -984,6 +984,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'Остановить отслеживание URL',
enableUrlTracker: 'Запустить отслеживание URL',
urlTrackerEnabled: 'Включено',
urlTrackerDisabled: 'Отключено',
originalUrl: 'Первоначальный URL',
redirectedTo: 'Перенаправлен в',
noRedirects: 'На данный момент нет ни одного перенаправления',
@@ -1726,6 +1726,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'URL izleyiciyi devre dışı bırakın',
enableUrlTracker: 'URL izleyiciyi etkinleştir',
urlTrackerEnabled: 'Etkin',
urlTrackerDisabled: 'Devre Dışı',
originalUrl: 'Orijinal URL',
redirectedTo: 'Yönlendirildi',
redirectUrlManagement: 'URL Yönetimini Yeniden Yönlendir',
@@ -982,6 +982,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'Зупинити відстеження URL',
enableUrlTracker: 'Запустити відстеження URL',
urlTrackerEnabled: 'Увімкнено',
urlTrackerDisabled: 'Вимкнено',
originalUrl: 'Початковий URL',
redirectedTo: 'Перенаправлено в',
noRedirects: 'На даний момент немає жодного перенаправлення',
@@ -2291,6 +2291,8 @@ export default {
redirectUrls: {
disableUrlTracker: 'Tắt theo dõi URL',
enableUrlTracker: 'Bật theo dõi URL',
urlTrackerEnabled: 'Đã bật',
urlTrackerDisabled: 'Đã tắt',
originalUrl: 'URL gốc',
redirectedTo: 'Chuyển hướng đến',
redirectUrlManagement: 'Quản lý URL chuyển hướng',
@@ -1038,6 +1038,8 @@ export default {
redirectUrls: {
disableUrlTracker: '停止網址追蹤器',
enableUrlTracker: '啟動網址追蹤器',
urlTrackerEnabled: '已啟用',
urlTrackerDisabled: '已停用',
originalUrl: '原本網址',
redirectedTo: '轉址成',
noRedirects: '沒有任何轉址',
@@ -1050,6 +1050,8 @@ export default {
redirectUrls: {
disableUrlTracker: '禁用 URL 跟踪程序',
enableUrlTracker: '启用 URL 跟踪程序',
urlTrackerEnabled: '已启用',
urlTrackerDisabled: '已禁用',
originalUrl: '原始网址',
redirectedTo: '已重定向至',
noRedirects: '未进行重定向',
File diff suppressed because one or more lines are too long
@@ -5203,9 +5203,9 @@ export class RedirectManagementService {
}
/**
* Sets the redirect URL tracking status.
* Deprecated. No longer changes the redirect URL tracking status.
*
* Updates the redirect URL tracking configuration according to the provided status.
* This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.
*/
public static postRedirectManagementStatus<ThrowOnError extends boolean = true>(options?: Options<PostRedirectManagementStatusData, ThrowOnError>) {
return (options?.client ?? client).post<PostRedirectManagementStatusResponses, PostRedirectManagementStatusErrors, ThrowOnError>({
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@ import {
state,
when,
} from '@umbraco-cms/backoffice/external/lit';
import { umbConfirmModal } from '@umbraco-cms/backoffice/modal';
import { umbConfirmModal, umbInfoModal } from '@umbraco-cms/backoffice/modal';
import { UmbDocumentRedirectManagementRepository } from '@umbraco-cms/backoffice/document';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
@@ -113,26 +113,19 @@ export class UmbDashboardRedirectManagementElement extends UmbLitElement {
this.#getRedirectData(this._search.value);
}
async #onRequestTrackerToggle() {
if (!this._trackerEnabled) {
this.#trackerToggle();
return;
}
await umbConfirmModal(this, {
headline: '#redirectUrls_disableUrlTracker',
content: '#redirectUrls_confirmDisable',
color: 'danger',
confirmLabel: '#actions_disable',
});
this.#trackerToggle();
}
async #trackerToggle() {
const { error } = await this.#repository.setStatus(!this._trackerEnabled);
if (error) return;
this._trackerEnabled = !this._trackerEnabled;
async #showTrackerInfo() {
const isEnabled = this._trackerEnabled;
await umbInfoModal(this, {
headline: isEnabled ? '#redirectUrls_disableUrlTracker' : '#redirectUrls_enableUrlTracker',
content: html`
<p>
${this.localize.term(
isEnabled ? 'redirectUrls_disableUrlTrackerInstruction' : 'redirectUrls_enableUrlTrackerInstruction',
)}
</p>
<p><code>Umbraco:CMS:WebRouting:DisableRedirectUrlTracking</code></p>
`,
}).catch(() => undefined);
}
override render() {
@@ -153,20 +146,24 @@ export class UmbDashboardRedirectManagementElement extends UmbLitElement {
@click=${this.#onSearch}
.state=${this._buttonState}></uui-button>
</div>
<uui-button
look="outline"
label=${this.localize.term('redirectUrls_disableUrlTracker')}
@click=${this.#onRequestTrackerToggle}></uui-button>
`,
() => html`
<div></div>
<uui-button
color="positive"
look="outline"
label=${this.localize.term('redirectUrls_enableUrlTracker')}
@click=${this.#onRequestTrackerToggle}></uui-button>
`,
() => html`<div></div>`,
)}
<uui-button
id="tracker-status"
compact
label=${this.localize.term(
this._trackerEnabled ? 'redirectUrls_urlTrackerEnabled' : 'redirectUrls_urlTrackerDisabled',
)}
@click=${this.#showTrackerInfo}>
<uui-tag color="default">
<uui-icon name="icon-info"></uui-icon>
<umb-localize
key=${this._trackerEnabled
? 'redirectUrls_urlTrackerEnabled'
: 'redirectUrls_urlTrackerDisabled'}></umb-localize>
</uui-tag>
</uui-button>
</div>
${when(
this._redirectData?.length,
@@ -287,6 +284,18 @@ export class UmbDashboardRedirectManagementElement extends UmbLitElement {
justify-content: space-between;
}
#tracker-status {
--uui-button-background-color: transparent;
--uui-button-background-color-hover: transparent;
}
uui-tag {
display: inline-flex;
align-items: center;
gap: var(--uui-size-1);
text-wrap: nowrap;
}
#search-wrapper {
display: flex;
gap: var(--uui-size-4);
@@ -2,6 +2,7 @@ import type { UmbDocumentRedirectFilterArgs } from './types.js';
import { UmbDocumentRedirectManagementServerDataSource } from './document-redirect-management.server.data-source.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbApi } from '@umbraco-cms/backoffice/extension-api';
import { UmbDeprecation } from '@umbraco-cms/backoffice/utils';
/**
* Repository for managing document redirect URLs.
@@ -25,8 +26,18 @@ export class UmbDocumentRedirectManagementRepository extends UmbControllerBase i
* @param {boolean} enabled - Whether the tracker should be enabled.
* @returns {*}
* @memberof UmbDocumentRedirectManagementRepository
* @deprecated Deprecated since v17. The backend endpoint is now a no-op; set the
* `Umbraco:CMS:WebRouting:DisableRedirectUrlTracking` configuration key instead.
* Scheduled for removal in Umbraco 19.
*/
async setStatus(enabled: boolean) {
new UmbDeprecation({
deprecated: 'UmbDocumentRedirectManagementRepository.setStatus()',
removeInVersion: '19.0.0',
solution:
'The backend endpoint is now a no-op. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.',
}).warn();
return this.#dataSource.setStatus(enabled);
}
@@ -48,6 +48,9 @@ export class UmbDocumentRedirectManagementServerDataSource {
* @param {boolean} enabled - Whether the tracker should be enabled.
* @returns {*}
* @memberof UmbDocumentRedirectManagementServerDataSource
* @deprecated Deprecated since v17. The backend endpoint is now a no-op; set the
* `Umbraco:CMS:WebRouting:DisableRedirectUrlTracking` configuration key instead.
* Scheduled for removal in Umbraco 19.
*/
async setStatus(enabled: boolean) {
const status = enabled ? RedirectStatusModel.ENABLED : RedirectStatusModel.DISABLED;
@@ -12,51 +12,77 @@ internal sealed class LogViewerServiceTests : UmbracoIntegrationTest
{
private ILogViewerService LogViewerService => GetRequiredService<ILogViewerService>();
private const string LogfileName = "UmbracoTraceLog.INTEGRATIONTEST.20230707.json";
// A pre-recorded log file copied from TestData/TestLogs/ into the configured log directory in
// OneTimeSetUp. Contains 362 valid Compact JSON entries from a real Umbraco test run and is the
// basis for the "Can_*" assertions below.
private const string SampleLogfileName = "UmbracoTraceLog.INTEGRATIONTEST.20230707.json";
private readonly DateTime _sampleStartDate = new(2023, 7, 7);
private readonly DateTime _sampleEndDate = new(2023, 7, 8);
private string _newLogfilePath;
// A second log file written in OneTimeSetUp that deliberately contains an unterminated JSON
// entry between two valid entries, to exercise corrupt-line recovery in LogViewerRepository.
// The date is deliberately outside the sample file's range so the exact-count assertions on
// the sample file are not influenced by these entries.
private const string CorruptLogfileName = "UmbracoTraceLog.WITHCORRUPTLINE.20240109.json";
private readonly DateTime _corruptStartDate = new(2024, 1, 9);
private readonly DateTime _corruptEndDate = new(2024, 1, 10);
private readonly DateTime _startDate = new(2023, 7, 7);
private readonly DateTime _endDate = new(2023, 7, 8);
private const string CorruptFileValidLineBefore =
"""{"@t":"2024-01-09T09:00:00.0000000Z","@mt":"First valid entry","SourceContext":"Test","ProcessId":1,"ProcessName":"Test","ThreadId":1,"MachineName":"TEST","Log4NetLevel":"INFO "}""";
// Truncated mid-string — mirrors the failure mode from https://github.com/umbraco/Umbraco-CMS/issues/22820
// (JsonReaderException: Unterminated string).
private const string CorruptFileTruncatedLine =
"""{"@t":"2024-01-09T09:00:01.0000000Z","@mt":"Truncated entry""";
private const string CorruptFileValidLineAfter =
"""{"@t":"2024-01-09T09:00:02.0000000Z","@mt":"Second valid entry","SourceContext":"Test","ProcessId":1,"ProcessName":"Test","ThreadId":1,"MachineName":"TEST","Log4NetLevel":"INFO "}""";
private string _sampleLogfilePath;
private string _corruptLogfilePath;
[OneTimeSetUp]
public void Setup()
{
// Create an example JSON log file to check results
// As a one time setup for all tets in this class/fixture
var testRoot = TestContext.CurrentContext.TestDirectory.Split("bin")[0];
var ioHelper = TestHelper.IOHelper;
var hostingEnv = TestHelper.GetHostingEnvironment();
var loggingConfiguration = TestHelper.GetLoggingConfiguration(hostingEnv);
var exampleLogfilePath = Path.Combine(testRoot, "TestData", "TestLogs", LogfileName);
string newLogfileDirPath = loggingConfiguration.LogDirectory;
_newLogfilePath = Path.Combine(newLogfileDirPath, LogfileName);
// Create/ensure Directory exists
var newLogfileDirPath = loggingConfiguration.LogDirectory;
ioHelper.EnsurePathExists(newLogfileDirPath);
// Copy the sample files
File.Copy(exampleLogfilePath, _newLogfilePath, true);
// Sample log file (good content, 362 entries).
var sampleLogfileSource = Path.Combine(testRoot, "TestData", "TestLogs", SampleLogfileName);
_sampleLogfilePath = Path.Combine(newLogfileDirPath, SampleLogfileName);
File.Copy(sampleLogfileSource, _sampleLogfilePath, true);
// Corrupt log file (two valid entries around a truncated one).
_corruptLogfilePath = Path.Combine(newLogfileDirPath, CorruptLogfileName);
var corruptContent = string.Join(
Environment.NewLine,
new[] { CorruptFileValidLineBefore, CorruptFileTruncatedLine, CorruptFileValidLineAfter }) + Environment.NewLine;
File.WriteAllText(_corruptLogfilePath, corruptContent);
}
[OneTimeTearDown]
public void TearDown()
{
// Cleanup & delete the example log & search files off disk
// Once all tests in this class/fixture have run
if (File.Exists(_newLogfilePath))
if (File.Exists(_sampleLogfilePath))
{
File.Delete(_newLogfilePath);
File.Delete(_sampleLogfilePath);
}
if (File.Exists(_corruptLogfilePath))
{
File.Delete(_corruptLogfilePath);
}
}
[Test]
public async Task Can_View_Logs()
{
var attempt = await LogViewerService.CanViewLogsAsync(_startDate, _endDate);
var attempt = await LogViewerService.CanViewLogsAsync(_sampleStartDate, _sampleEndDate);
Assert.Multiple(() =>
{
@@ -68,7 +94,7 @@ internal sealed class LogViewerServiceTests : UmbracoIntegrationTest
[Test]
public async Task Can_Get_Logs()
{
var attempt = await LogViewerService.GetPagedLogsAsync(_startDate, _endDate, 0, int.MaxValue);
var attempt = await LogViewerService.GetPagedLogsAsync(_sampleStartDate, _sampleEndDate, 0, int.MaxValue);
Assert.Multiple(() =>
{
@@ -84,8 +110,8 @@ internal sealed class LogViewerServiceTests : UmbracoIntegrationTest
public async Task Can_Get_Logs_By_Filter_Expression()
{
var attempt = await LogViewerService.GetPagedLogsAsync(
_startDate,
_endDate,
_sampleStartDate,
_sampleEndDate,
0,
int.MaxValue,
filterExpression: "@Level='Error'");
@@ -104,7 +130,7 @@ internal sealed class LogViewerServiceTests : UmbracoIntegrationTest
public async Task Can_Get_Logs_By_Log_Levels()
{
var attempt =
await LogViewerService.GetPagedLogsAsync(_startDate, _endDate, 0, int.MaxValue, logLevels: new[] {"Error"});
await LogViewerService.GetPagedLogsAsync(_sampleStartDate, _sampleEndDate, 0, int.MaxValue, logLevels: new[] {"Error"});
Assert.Multiple(() =>
{
@@ -119,7 +145,7 @@ internal sealed class LogViewerServiceTests : UmbracoIntegrationTest
[Test]
public async Task Can_Get_Log_Count()
{
var attempt = await LogViewerService.GetLogLevelCountsAsync(_startDate, _endDate);
var attempt = await LogViewerService.GetLogLevelCountsAsync(_sampleStartDate, _sampleEndDate);
Assert.Multiple(() =>
{
@@ -137,7 +163,7 @@ internal sealed class LogViewerServiceTests : UmbracoIntegrationTest
[Test]
public async Task Can_Get_Message_Templates()
{
var attempt = await LogViewerService.GetMessageTemplatesAsync(_startDate, _endDate, 0, int.MaxValue);
var attempt = await LogViewerService.GetMessageTemplatesAsync(_sampleStartDate, _sampleEndDate, 0, int.MaxValue);
Assert.Multiple(() =>
{
@@ -214,4 +240,42 @@ internal sealed class LogViewerServiceTests : UmbracoIntegrationTest
Assert.AreEqual(savedAttempt.Result, deleteAttempt.Result);
});
}
/// <summary>
/// Verifies that a log file containing an unterminated JSON entry between two valid entries
/// does not prevent the log viewer from returning the surrounding valid entries.
/// Regression coverage for https://github.com/umbraco/Umbraco-CMS/issues/22820.
/// </summary>
[Test]
public async Task Reads_Valid_Lines_Either_Side_Of_Corrupt_Line()
{
var attempt = await LogViewerService.GetPagedLogsAsync(_corruptStartDate, _corruptEndDate, 0, int.MaxValue);
Assert.Multiple(() =>
{
Assert.IsTrue(attempt.Success);
Assert.AreEqual(LogViewerOperationStatus.Success, attempt.Status);
Assert.IsNotNull(attempt.Result);
Assert.AreEqual(2, attempt.Result.Total, "Expected the two valid lines either side of the corrupt line to be returned.");
Assert.That(attempt.Result.Items.Select(x => x.RenderedMessage), Is.EquivalentTo(new[] { "First valid entry", "Second valid entry" }));
});
}
/// <summary>
/// Verifies that the level-counts request returns successfully and tallies the surrounding valid
/// entries in the presence of an unterminated JSON entry in the source file.
/// </summary>
[Test]
public async Task Get_Log_Count_Succeeds_With_Corrupt_Line()
{
var attempt = await LogViewerService.GetLogLevelCountsAsync(_corruptStartDate, _corruptEndDate);
Assert.Multiple(() =>
{
Assert.IsTrue(attempt.Success);
Assert.AreEqual(LogViewerOperationStatus.Success, attempt.Status);
Assert.IsNotNull(attempt.Result);
Assert.AreEqual(2, attempt.Result.Information);
});
}
}