Skip to content
Merged
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
152 changes: 152 additions & 0 deletions src/NoteBookmark.BlazorApp.Tests/Tests/NewsFeedServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
using System.Net;
using System.Text;
using Bunit;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using NoteBookmark.BlazorApp.Components.Pages;
using NoteBookmark.BlazorApp.Tests.Helpers;

namespace NoteBookmark.BlazorApp.Tests.Tests;

public sealed class NewsFeedServiceTests : BunitContext
{
[Fact]
public async Task GetHtmlAsync_RendersMarkdownRewritesRelativeAssetsAndSanitizesUrls()
{
var requestedUrls = new List<string>();
var handler = new DelegateHttpMessageHandler(request =>
{
requestedUrls.Add(request.RequestUri!.AbsoluteUri);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"# Product Updates\n\n![Logo](assets/logo.png)\n\n![Chart](./assets/sub/chart(1).png)\n\n![External](https://example.com/banner.png)\n\n<script>alert(1)</script>\n\n[Safe](https://example.com) [Email](mailto:test@example.com) [Unsafe](javascript:alert(1))",
Encoding.UTF8,
"text/plain")
};
});
var service = CreateService(handler);

var html = await service.GetHtmlAsync();

requestedUrls.Should().Equal("https://raw.githubusercontent.com/fboucher/NoteBookmark/main/docs/news.md");
html.Should().Contain("<h1>Product Updates</h1>");
html.Should().Contain("src=\"https://raw.githubusercontent.com/fboucher/NoteBookmark/main/docs/assets/logo.png\" alt=\"Logo\"");
html.Should().Contain("src=\"https://raw.githubusercontent.com/fboucher/NoteBookmark/main/docs/assets/sub/chart%281%29.png\" alt=\"Chart\"");
html.Should().Contain("src=\"https://example.com/banner.png\" alt=\"External\"");
html.Should().NotContain("<script>");
html.Should().Contain("href=\"https://example.com\"");
html.Should().Contain("href=\"mailto:test@example.com\"");
html.Should().Contain("href=\"#\"");
}

[Fact]
public async Task GetHtmlAsync_CachesRenderedFeedAcrossCalls()
{
var callCount = 0;
var handler = new DelegateHttpMessageHandler(_ =>
{
callCount++;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("# Cached Update", Encoding.UTF8, "text/plain")
};
});
var service = CreateService(handler);

var first = await service.GetHtmlAsync();
var second = await service.GetHtmlAsync();

first.Should().Contain("<h1>Cached Update</h1>");
second.Should().Be(first);
callCount.Should().Be(1);
}

[Fact]
public async Task GetHtmlAsync_WhenFetchFails_ReturnsFriendlyFallbackWithoutCachingFailure()
{
var shouldFail = true;
var handler = new DelegateHttpMessageHandler(_ =>
{
if (shouldFail)
{
throw new HttpRequestException("Network error");
}

return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("# Recovered Feed", Encoding.UTF8, "text/plain")
};
});
var service = CreateService(handler);

var fallbackHtml = await service.GetHtmlAsync();
shouldFail = false;
var recoveredHtml = await service.GetHtmlAsync();

fallbackHtml.Should().Be("<p>Welcome to NoteBookmark! Check back here for product updates.</p>");
recoveredHtml.Should().Contain("<h1>Recovered Feed</h1>");
}

[Fact]
public async Task GetHtmlAsync_IgnoresImageSyntaxInsideCodeBlocksAndDisallowsTraversalPaths()
{
var handler = new DelegateHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"```markdown\n![CodeImage](assets/code.png)\n```\n\n![Traversal](assets/../secret.png)",
Encoding.UTF8,
"text/plain")
});
var service = CreateService(handler);

var html = await service.GetHtmlAsync();

html.Should().Contain("![CodeImage](assets/code.png)");
html.Should().Contain("src=\"assets/../secret.png\"");
html.Should().NotContain("https://raw.githubusercontent.com/fboucher/NoteBookmark/main/docs/assets/code.png");
html.Should().NotContain("https://raw.githubusercontent.com/fboucher/NoteBookmark/main/docs/assets/secret.png");
}

[Fact]
public void Home_RendersNewsAndUpdatesSectionFromService()
{
this.AddFluentUI();
var newsFeedMock = new Mock<INewsFeedService>();
newsFeedMock.Setup(s => s.GetHtmlAsync()).ReturnsAsync("<p>Latest release notes</p>");
Services.AddSingleton(newsFeedMock.Object);

var cut = Render<Home>();

var section = cut.Find("section.news-section");
section.GetAttribute("aria-labelledby").Should().Be("news-heading");
cut.Find("#news-heading").TextContent.Should().Be("News & Updates");
cut.Find(".news-content").InnerHtml.Should().Be("<p>Latest release notes</p>");
}

[Fact]
public void Home_WhenNewsFeedReturnsFallback_DisplaysWelcomeMessage()
{
this.AddFluentUI();
var service = CreateService(new DelegateHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)));
Services.AddSingleton<INewsFeedService>(service);

var cut = Render<Home>();

cut.Find(".news-content").TextContent.Should().Contain("Welcome to NoteBookmark! Check back here for product updates.");
}

private static NewsFeedService CreateService(HttpMessageHandler handler)
{
var logger = new Mock<ILogger<NewsFeedService>>();
return new NewsFeedService(new HttpClient(handler), logger.Object);
}

private sealed class DelegateHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(responder(request));
}
}
19 changes: 18 additions & 1 deletion src/NoteBookmark.BlazorApp/Components/Pages/Home.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.FluentUI.AspNetCore.Components
@inject NavigationManager Navigation
@inject INewsFeedService NewsFeed

<PageTitle>Home - NoteBookmark</PageTitle>

Expand Down Expand Up @@ -40,6 +41,13 @@
</FluentCard>
</FluentStack>

<FluentCard Width="932px">
<section class="news-section" aria-labelledby="news-heading">
<h3 id="news-heading">News &amp; Updates</h3>
<div class="news-content">@((MarkupString)newsHtml)</div>
</section>
</FluentCard>

<FluentDivider />

<FluentStack Orientation="Orientation.Vertical" VerticalGap="8">
Expand All @@ -65,4 +73,13 @@
</a>
</FluentStack>
</FluentStack>
</FluentStack>
</FluentStack>

@code {
private string newsHtml = NewsFeedMarkdownRenderer.FallbackHtml;

protected override async Task OnInitializedAsync()
{
newsHtml = await NewsFeed.GetHtmlAsync();
}
}
8 changes: 8 additions & 0 deletions src/NoteBookmark.BlazorApp/INewsFeedService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Threading.Tasks;

namespace NoteBookmark.BlazorApp;

public interface INewsFeedService
{
Task<string> GetHtmlAsync();
}
68 changes: 68 additions & 0 deletions src/NoteBookmark.BlazorApp/NewsFeedService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NoteBookmark.SharedUI;

namespace NoteBookmark.BlazorApp;

public sealed class NewsFeedService : INewsFeedService
{
private readonly HttpClient _httpClient;
private readonly ILogger<NewsFeedService> _logger;
private readonly string _newsFeedUrl;
private readonly string _newsAssetsUrl;
private readonly SemaphoreSlim _loadLock = new(1, 1);
private string? _cachedHtml;

public NewsFeedService(
HttpClient httpClient,
ILogger<NewsFeedService> logger,
string newsFeedUrl = NewsFeedMarkdownRenderer.DefaultNewsFeedUrl,
string newsAssetsUrl = NewsFeedMarkdownRenderer.DefaultNewsAssetsUrl)
{
_httpClient = httpClient;
_logger = logger;
_newsFeedUrl = newsFeedUrl;
_newsAssetsUrl = newsAssetsUrl;
}

public async Task<string> GetHtmlAsync()
{
if (_cachedHtml is not null)
{
return _cachedHtml;
}

await _loadLock.WaitAsync();
try
{
if (_cachedHtml is not null)
{
return _cachedHtml;
}

var markdown = await _httpClient.GetStringAsync(_newsFeedUrl);
if (string.IsNullOrWhiteSpace(markdown))
{
return NewsFeedMarkdownRenderer.FallbackHtml;
}

_cachedHtml = NewsFeedMarkdownRenderer.RenderToHtml(
markdown,
relativePath => NewsFeedMarkdownRenderer.BuildAssetUrl(relativePath, _newsAssetsUrl));

return _cachedHtml;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to download the news feed; showing the welcome message.");
return NewsFeedMarkdownRenderer.FallbackHtml;
}
finally
{
_loadLock.Release();
}
}
}
4 changes: 4 additions & 0 deletions src/NoteBookmark.BlazorApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();
builder.AddAzureTableClient("nb-tables");

Check warning on line 13 in src/NoteBookmark.BlazorApp/Program.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'AspireTablesExtensions.AddAzureTableClient(IHostApplicationBuilder, string, Action<AzureDataTablesSettings>?, Action<IAzureClientBuilder<TableServiceClient, TableClientOptions>>?)' is obsolete: 'Use AddAzureTableServiceClient instead. This method will be removed in a future version.'

// Add HTTP client for API calls
builder.Services.AddHttpClient<PostNoteClient>(client =>
Expand All @@ -19,6 +19,10 @@
});
builder.Services.AddTransient<IDataService>(sp => sp.GetRequiredService<PostNoteClient>());
builder.Services.AddScoped<IUrlLauncher, JsUrlLauncher>();
builder.Services.AddHttpClient(nameof(NewsFeedService));
builder.Services.AddSingleton<INewsFeedService>(sp => new NewsFeedService(
sp.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(NewsFeedService)),
sp.GetRequiredService<ILogger<NewsFeedService>>()));

// Register server-side AI settings provider (direct database access, unmasked)
builder.Services.AddScoped<AISettingsProvider>();
Expand Down
Loading
Loading