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
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@
.nuget-packages
Petabit/docs
Petabit.Tests
**/App_Data
artifacts
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ jobs:
- name: Run tests
run: dotnet test Petabit.sln --configuration Release --no-build --verbosity normal

- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 24
package-manager-cache: false

- name: Test ISS video playback
run: node --test Petabit.Tests/*.test.cjs Petabit.Tests/*.test.mjs

docker-build:
name: Build Docker image
runs-on: ubuntu-latest
Expand Down
35 changes: 18 additions & 17 deletions .github/workflows/station-status-freshness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,30 @@ on:
- "Petabit.Tests/StationStatusTests.cs"
- ".github/workflows/station-status-freshness.yml"
schedule:
- cron: "0 7 * * 1"
- cron: "0 * * * *"
workflow_dispatch:

permissions:
contents: read

jobs:
verify-station-status:
name: Verify curated ISS data age
name: Verify live NASA synchronization
runs-on: ubuntu-latest
timeout-minutes: 10

timeout-minutes: 3
steps:
- name: Check out repository
uses: actions/checkout@v7

- name: Set up .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x

- name: Restore dependencies
run: dotnet restore Petabit.sln --configfile NuGet.Config

- name: Check station-status freshness
run: dotnet test Petabit.Tests/Petabit.Tests.csproj --configuration Release --no-restore --filter "Category=StationStatusFreshness"
- name: Check production station status
env:
SITE_URL: https://petabit-production.up.railway.app
run: |
curl --fail --silent --show-error --connect-timeout 5 --max-time 20 \
"$SITE_URL/Home/StationData" |
jq --exit-status '
.stationStatusCheckedAt != null and
(.stationStatusIsStale == false) and
(.stationStatusRefreshFailed == false) and
(.stationStatusNeedsReview == false) and
(.stationStatusPersistenceFailed == false) and
(.astronautCount == (.astronauts | length)) and
((.dockedVehicles | length) > 0)
'
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -363,3 +363,4 @@ MigrationBackup/
FodyWeavers.xsd
# Local NuGet state used by isolated builds.
.nuget-appdata/
**/App_Data/
15 changes: 15 additions & 0 deletions Petabit.Tests/ApplicationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public ApplicationTests(WebApplicationFactory<Program> factory)
builder.ConfigureLogging(logging => logging.ClearProviders());
builder.ConfigureServices(services =>
{
services.Configure<Petabit.Services.StationSyncOptions>(options => options.Enabled = false);
services.AddDataProtection().UseEphemeralDataProtectionProvider();
services.RemoveAll<IHttpClientFactory>();
services.AddSingleton<IHttpClientFactory>(
Expand Down Expand Up @@ -418,6 +419,19 @@ public async Task GlobalRateLimitProtectsDynamicPagesButNotLiveness()
private HttpClient CreateClientWithIssResponse(HttpStatusCode statusCode, string content)
=> CreateClientWithHandler(new StubHttpMessageHandler(statusCode, content));

[Fact]
public async Task StationStatusRemainsAvailableWhenPositionApiFails()
{
using var client = CreateClientWithIssResponse(HttpStatusCode.ServiceUnavailable, "Unavailable");
var response = await client.GetAsync("/Home/StationData");
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("\"astronautCount\":11", body);
Assert.Contains("Crew-13 Dragon", body);
Assert.Contains("\"stationStatusIsStale\":true", body);
Assert.Equal("no-store,no-cache", response.Headers.CacheControl?.ToString()?.Replace(" ", ""));
}

private HttpClient CreateClientWithHandler(HttpMessageHandler handler)
{
return new WebApplicationFactory<Program>()
Expand All @@ -426,6 +440,7 @@ private HttpClient CreateClientWithHandler(HttpMessageHandler handler)
builder.ConfigureLogging(logging => logging.ClearProviders());
builder.ConfigureServices(services =>
{
services.Configure<Petabit.Services.StationSyncOptions>(options => options.Enabled = false);
services.AddDataProtection().UseEphemeralDataProtectionProvider();
services.RemoveAll<IHttpClientFactory>();
services.AddSingleton<IHttpClientFactory>(
Expand Down
25 changes: 25 additions & 0 deletions Petabit.Tests/NasaLiveVideoTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Petabit.Services;
using Xunit;

namespace Petabit.Tests;

public class NasaLiveVideoTests
{
[Fact]
public void CameraIdComesFromNasaEmbedRatherThanOtherPageLinks()
{
var html = """
<a href="https://www.youtube.com/watch?v=other_video">Channel</a>
<iframe title="NASA live camera" src="https://www.youtube.com/embed/awQzjn72bI0?autoplay=1"></iframe>
""";
Assert.Equal("awQzjn72bI0", NasaLiveVideoService.ParseVideoId(html));
}

[Theory]
[InlineData("<iframe src='https://www.youtube.com.attacker.example/embed/awQzjn72bI0'></iframe>")]
[InlineData("<iframe src='https://attacker.example/awQzjn72bI0'></iframe>")]
[InlineData("<iframe src='https://www.youtube.com/embed/bad-id'></iframe>")]
[InlineData("<a href='https://www.youtube.com/embed/awQzjn72bI0'>Not a camera</a>")]
public void UnsupportedOrUnexpectedSourceIsRejected(string html)
=> Assert.Throws<FormatException>(() => NasaLiveVideoService.ParseVideoId(html));
}
123 changes: 123 additions & 0 deletions Petabit.Tests/NasaStationParserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using Petabit.Models;
using Petabit.Services;
using Xunit;

namespace Petabit.Tests;

public class NasaStationParserTests
{
private static NasaArticle Article(string title, string text) => new(
"test", title, "https://www.nasa.gov/blogs/spacestation/test/",
StationStatus.LastVerified.AddDays(5), text);

[Fact]
public void ConfigurationUsesDatedCaptionInsteadOfOutdatedPageCounter()
{
var observation = NasaStationParser.ParseVehicles("""
<div>TODAY: 4 spacecraft</div><figcaption>
Oct. 1, 2026: International Space Station Configuration. Six spaceships are attached to the space station including the SpaceX Crew-12 and Crew-13 Dragons, Northrop Grumman’s Cygnus XL, the Soyuz MS-29 crew spacecraft, and the Progress 95 and 96 resupply ships.
</figcaption>
""");
Assert.Equal(6, observation.Vehicles.Length);
Assert.Contains(observation.Vehicles, v => v.Name == "Crew-13 Dragon");
Assert.Contains(observation.Vehicles, v => v.Name == "Progress 96");
Assert.Equal(new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.Zero), observation.Date);
}

[Fact]
public void UnknownVehicleDoesNotProduceAnIncompleteConfiguration()
=> Assert.Throws<FormatException>(() => NasaStationParser.ParseVehicles("""
<figcaption>Oct. 1, 2026: International Space Station Configuration. Two spaceships are attached including Crew-13 Dragon and a new spacecraft.</figcaption>
"""));

[Theory]
[InlineData("NASA, SpaceX Set No Earlier Than Oct. 7 for Crew-12 Departure", "Crew-12 is scheduled to undock tomorrow.")]
[InlineData("Crew-12 Prepares to Depart", "Crew-12 will depart tomorrow.")]
[InlineData("Crew-14 Approaching Station", "Crew-14 is scheduled to dock tomorrow.")]
public void AnnouncementsDoNotChangeStatus(string title, string text)
{
var initial = StationSnapshot.Bootstrap();
var result = NasaStationParser.ApplyArticle(initial, Article(title, text));
Assert.Equal(initial, result.Snapshot);
Assert.Equal("news", result.Kind);
}

[Fact]
public void UndockingRemovesOnlyTheDepartingMissionAndItsCrew()
{
var result = NasaStationParser.ApplyArticle(StationSnapshot.Bootstrap(), Article(
"Crew-12 Dragon Undocks from Station", "The Crew-12 Dragon undocked from the International Space Station at 1:05 a.m. EDT."));
Assert.Equal("departure", result.Kind);
Assert.Equal(7, result.Snapshot.Crew.Length);
Assert.Equal(5, result.Snapshot.DockedVehicles.Length);
Assert.DoesNotContain(result.Snapshot.Crew, c => c.Mission == "Crew-12");
Assert.Contains(result.Snapshot.DockedVehicles, v => v.Name == "Crew-13 Dragon");
}

[Fact]
public void DockingDoesNotAddCrewBeforeHatchOpening()
{
var result = NasaStationParser.ApplyArticle(StationSnapshot.Bootstrap(), Article(
"Dragon Docks Bringing SpaceX Crew-14 to Station", "The Crew-14 Dragon docked to the station at 7:05 p.m. EDT."));
Assert.Equal("arrival", result.Kind);
Assert.Equal(11, result.Snapshot.Crew.Length);
Assert.Equal(7, result.Snapshot.DockedVehicles.Length);
}

[Fact]
public void HatchOpeningAddsTheNamedCrewWithAgencyAndMission()
{
var result = NasaStationParser.ApplyArticle(StationSnapshot.Bootstrap(), Article(
"Crew-14 Members Enter Station, Join Expedition 76",
"NASA astronauts Alice Smith and Bob Jones, CSA (Canadian Space Agency) astronaut Carol White, and Roscosmos cosmonaut Dmitry Petrov have entered the International Space Station following hatch opening."));
Assert.Equal("crew-arrival", result.Kind);
Assert.Equal(15, result.Snapshot.Crew.Length);
Assert.Contains(result.Snapshot.Crew, c => c.Name == "Carol White" && c.Agency == "CSA" && c.Mission == "Crew-14");
}

[Fact]
public void UnclearCompletedEventKeepsPreviousStatusAndRequestsReview()
{
var initial = StationSnapshot.Bootstrap();
var result = NasaStationParser.ApplyArticle(initial, Article("Dragon Undocks from Station", "Dragon undocked from the station."));
Assert.True(result.Snapshot.NeedsReview);
Assert.Equal(initial.Crew, result.Snapshot.Crew);
Assert.Equal(initial.DockedVehicles, result.Snapshot.DockedVehicles);
}

[Fact]
public void FeedIgnoresHistoricalImageCaptionsAndUsesUtcPublicationTime()
{
var articles = NasaStationParser.ParseFeed("""
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><item>
<title>Crew-12 Prepares to Depart</title><link>https://www.nasa.gov/blogs/spacestation/test/</link>
<pubDate>Sat, 03 Oct 2026 19:19:36 +0000</pubDate>
<content:encoded><![CDATA[<figcaption>Dragon undocked last year.</figcaption><p>Departure is planned.</p>]]></content:encoded>
</item></channel></rss>
""");
Assert.Equal("Departure is planned.", Assert.Single(articles).Text);
Assert.Equal(TimeSpan.Zero, articles[0].PublishedAt.Offset);
}

[Fact]
public void FeedRejectsNonNasaLinks()
=> Assert.Throws<FormatException>(() => NasaStationParser.ParseFeed("""
<rss><channel><item><link>https://attacker.example/</link></item></channel></rss>
"""));

[Fact]
public void FeedRejectsExternalXmlEntities()
=> Assert.Throws<System.Xml.XmlException>(() => NasaStationParser.ParseFeed("""
<!DOCTYPE rss [<!ENTITY external SYSTEM "file:///etc/passwd">]><rss><channel>&external;</channel></rss>
"""));

[Fact]
public void NewerVehicleDiagramDoesNotPreventConfirmedCrewDeparture()
{
var initial = StationSnapshot.Bootstrap() with { VehiclesUpdatedAt = StationStatus.LastVerified.AddDays(6) };
var result = NasaStationParser.ApplyArticle(initial, Article("Crew-12 Dragon Undocks from Station",
"The Crew-12 Dragon undocked from the International Space Station."));
Assert.Equal(7, result.Snapshot.Crew.Length);
Assert.Equal(initial.DockedVehicles, result.Snapshot.DockedVehicles);
}
}
108 changes: 108 additions & 0 deletions Petabit.Tests/StarlinkTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Text.Json;
using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging.Abstractions;
using Petabit.Services;
using Xunit;

namespace Petabit.Tests;

public class StarlinkTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "petabit-starlink-tests", Guid.NewGuid().ToString("N"));
private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-10-05T00:00:00Z");
private static object Catalog(int id, string status, string decay = "", string type = "ORB") => new
{
OBJECT_NAME = $"STARLINK-{id}", NORAD_CAT_ID = id, OBJECT_TYPE = "PAY", OPS_STATUS_CODE = status,
ORBIT_CENTER = "EA", ORBIT_TYPE = type, DECAY_DATE = decay
};
private static object Orbit(int id, double eccentricity = .0002923) => new
{
OBJECT_NAME = $"STARLINK-{id}", NORAD_CAT_ID = id, EPOCH = "2026-10-04T22:36:12.73824",
MEAN_MOTION = 15.66040205, ECCENTRICITY = eccentricity, INCLINATION = 53.1448,
RA_OF_ASC_NODE = 256.2279, ARG_OF_PERICENTER = 92.7295, MEAN_ANOMALY = 267.4056,
BSTAR = .00047264149, MEAN_MOTION_DOT = .00049034, MEAN_MOTION_DDOT = 0
};
[Fact]
public void OperationalIsStrictlyPlusNotAllActiveAndDecayedObjectsAreExcluded()
{
var catalog = JsonSerializer.Serialize(new[] { Catalog(100800, "+"), Catalog(2, "P"), Catalog(3, "S"),
Catalog(4, "-"), Catalog(5, "D", "2026-10-01"), Catalog(6, "+", type: "IMP"), Catalog(7, "+") });
var state = StarlinkService.Parse(catalog, JsonSerializer.Serialize(new[] { Orbit(100800), Orbit(2), Orbit(100800) }), Now);
Assert.Equal(2, state.OperationalCount);
Assert.Equal(5, state.OnOrbitCount);
Assert.Equal(1, state.PartiallyOperationalCount);
Assert.Single(state.Elements);
Assert.Equal(100800, state.Elements[0].GetProperty("NORAD_CAT_ID").GetInt32());
Assert.Equal(DateTimeOffset.Parse("2026-10-04T22:36:12.73824Z"), state.OldestEpoch);
}
[Theory]
[InlineData("[]", "[]")]
[InlineData("{}", "[]")]
public void EmptyOrUnexpectedResponsesAreRejected(string catalog, string elements)
=> Assert.Throws<FormatException>(() => StarlinkService.Parse(catalog, elements, Now));
[Fact]
public void InvalidOrbitIsRejectedRatherThanReplacingLastGoodSnapshot()
=> Assert.Throws<FormatException>(() => StarlinkService.Parse(JsonSerializer.Serialize(new[] { Catalog(1, "+") }),
JsonSerializer.Serialize(new[] { Orbit(1, 2) }), Now));

private StarlinkService Create(Handler handler) => new(new Factory(handler), NullLogger<StarlinkService>.Instance,
new EnvironmentStub { ContentRootPath = directory });

[Fact]
public async Task RepeatedPingAndRestartReuseThePersistedTwoHourCache()
{
var handler = new Handler();
var service = Create(handler);
var first = await service.GetAsync(CancellationToken.None);
Assert.Same(first, await service.GetAsync(CancellationToken.None));
Assert.Equal(2, handler.Requests);
var restarted = Create(handler);
Assert.Equal(first.OperationalCount, (await restarted.GetAsync(CancellationToken.None)).OperationalCount);
Assert.Equal(2, handler.Requests);
}

[Fact]
public async Task FailedRefreshKeepsLastGoodCountAndDoesNotRepeatedlyDownload()
{
Directory.CreateDirectory(Path.Combine(directory, "App_Data"));
var snapshot = StarlinkService.Parse(JsonSerializer.Serialize(new[] { Catalog(1, "+") }),
JsonSerializer.Serialize(new[] { Orbit(1) }), Now) with { RetrievedAt = DateTimeOffset.UtcNow.AddHours(-3) };
await File.WriteAllTextAsync(Path.Combine(directory, "App_Data", "starlink-status.json"), JsonSerializer.Serialize(snapshot));
var handler = new Handler { Unavailable = true };
var service = Create(handler);
var result = await service.GetAsync(CancellationToken.None);
Assert.Equal(snapshot.RetrievedAt, result.RetrievedAt);
Assert.True(service.RefreshFailed);
Assert.Equal(1, result.OperationalCount);
await service.GetAsync(CancellationToken.None);
Assert.Equal(2, handler.Requests);
}

public void Dispose() { if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); }
private class Factory(Handler handler) : IHttpClientFactory
{ public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); }
private class Handler : HttpMessageHandler
{
public int Requests;
public bool Unavailable;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref Requests);
var json = request.RequestUri!.AbsoluteUri == StarlinkService.CatalogUrl
? JsonSerializer.Serialize(new[] { Catalog(1, "+") }) : JsonSerializer.Serialize(new[] { Orbit(1) });
return Task.FromResult(new HttpResponseMessage(Unavailable ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK)
{ Content = new StringContent(json) });
}
}
private class EnvironmentStub : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "Petabit";
public string EnvironmentName { get; set; } = "Testing";
public string ContentRootPath { get; set; } = "";
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
public string WebRootPath { get; set; } = "";
public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider();
}
}
11 changes: 5 additions & 6 deletions Petabit.Tests/StationStatusTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,11 @@ public void StatusBecomesStaleAfterMaximumVerificationAge()
}

[Fact]
[Trait("Category", "StationStatusFreshness")]
public void CuratedStationStatusWasVerifiedRecently()
public void OfflineBootstrapDoesNotPretendToHaveCheckedNasaLive()
{
Assert.False(
StationStatus.IsStale(DateTimeOffset.UtcNow),
$"Curated ISS status was last verified on {StationStatus.LastVerified:yyyy-MM-dd}. " +
"Check the NASA source, update the data and move LastVerified forward.");
var state = StationSnapshot.Bootstrap();
Assert.Null(state.CheckedAt);
Assert.Equal(11, state.Crew.Length);
Assert.Contains(state.DockedVehicles, v => v.Name == "Crew-13 Dragon");
}
}
Loading
Loading