Compare commits
2 Commits
88615a95e9
...
42e62c1ed2
| Author | SHA1 | Date | |
|---|---|---|---|
| 42e62c1ed2 | |||
| 08486667c3 |
@@ -44,4 +44,19 @@ public static class Csv
|
|||||||
return value;
|
return value;
|
||||||
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defuses spreadsheet formula / DDE injection: when a cell would start with a formula
|
||||||
|
/// trigger (<c>= + - @</c>, tab or CR) it is prefixed with an apostrophe so Excel /
|
||||||
|
/// LibreOffice render it as text. Apply to USER-supplied or SCRAPED text fields (notes,
|
||||||
|
/// event titles) before they enter a row — numeric/date cells your own code formats are
|
||||||
|
/// trusted and don't need it (keeping them numeric for analysis).
|
||||||
|
/// </summary>
|
||||||
|
public static string NeutralizeFormula(string? field)
|
||||||
|
{
|
||||||
|
var value = field ?? string.Empty;
|
||||||
|
return value.Length > 0 && value[0] is '=' or '+' or '-' or '@' or '\t' or '\r'
|
||||||
|
? "'" + value
|
||||||
|
: value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ public sealed class ExportToCsvUseCase
|
|||||||
{
|
{
|
||||||
b.PlacedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
b.PlacedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
||||||
Title(titles, b.EventId),
|
Title(titles, b.EventId),
|
||||||
b.EventId.Value,
|
Csv.NeutralizeFormula(b.EventId.Value),
|
||||||
b.Selection.Type.ToString(),
|
b.Selection.Type.ToString(),
|
||||||
b.Selection.Side.ToString(),
|
b.Selection.Side.ToString(),
|
||||||
b.Selection.Value?.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
|
b.Selection.Value?.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
|
||||||
@@ -68,7 +68,7 @@ public sealed class ExportToCsvUseCase
|
|||||||
b.Stake.ToString(CultureInfo.InvariantCulture),
|
b.Stake.ToString(CultureInfo.InvariantCulture),
|
||||||
b.Outcome.ToString(),
|
b.Outcome.ToString(),
|
||||||
b.NetProfit?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
|
b.NetProfit?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
|
||||||
b.Notes ?? string.Empty,
|
Csv.NeutralizeFormula(b.Notes),
|
||||||
});
|
});
|
||||||
|
|
||||||
return await WriteAsync("journal", Csv.Document(header, rows), ct).ConfigureAwait(false);
|
return await WriteAsync("journal", Csv.Document(header, rows), ct).ConfigureAwait(false);
|
||||||
@@ -93,7 +93,7 @@ public sealed class ExportToCsvUseCase
|
|||||||
{
|
{
|
||||||
b.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
b.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
||||||
Title(titles, b.EventId),
|
Title(titles, b.EventId),
|
||||||
b.EventId.Value,
|
Csv.NeutralizeFormula(b.EventId.Value),
|
||||||
b.PickedSide.ToString(),
|
b.PickedSide.ToString(),
|
||||||
b.Rate.ToString(CultureInfo.InvariantCulture),
|
b.Rate.ToString(CultureInfo.InvariantCulture),
|
||||||
b.Stake.ToString(CultureInfo.InvariantCulture),
|
b.Stake.ToString(CultureInfo.InvariantCulture),
|
||||||
@@ -127,6 +127,8 @@ public sealed class ExportToCsvUseCase
|
|||||||
return titles;
|
return titles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Titles are scraped ("Side1 vs Side2") so they're treated as untrusted text and
|
||||||
|
// neutralized against CSV/formula injection.
|
||||||
private static string Title(IReadOnlyDictionary<DomainEventId, string> titles, DomainEventId id) =>
|
private static string Title(IReadOnlyDictionary<DomainEventId, string> titles, DomainEventId id) =>
|
||||||
titles.TryGetValue(id, out var t) ? t : id.Value;
|
Csv.NeutralizeFormula(titles.TryGetValue(id, out var t) ? t : id.Value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,23 +55,33 @@ public sealed class UpdatePlacedBetUseCase
|
|||||||
"The event must already be present in the scrape store.");
|
"The event must already be present in the scrape store.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve the original entry time; re-grade from Pending so a changed
|
// Only the selection or the event affects grading. When neither changed (e.g. a
|
||||||
// selection/event settles against the current result.
|
// stake/notes-only edit) keep the existing outcome — re-grading from Pending here
|
||||||
|
// would SILENTLY UN-SETTLE a won/lost bet whose result row has since been pruned by
|
||||||
|
// snapshot retention (the journal is FK-free and outlives result pruning). A
|
||||||
|
// still-Pending bet is always (re)graded, mirroring RecordPlacedBetUseCase.
|
||||||
|
var gradingInputChanged = !existing.EventId.Equals(eventId)
|
||||||
|
|| !existing.Selection.Equals(selection);
|
||||||
|
var regrade = gradingInputChanged || existing.Outcome == BetOutcome.Pending;
|
||||||
|
|
||||||
var toPersist = new PlacedBet(
|
var toPersist = new PlacedBet(
|
||||||
Id: id,
|
Id: id,
|
||||||
EventId: eventId,
|
EventId: eventId,
|
||||||
Selection: selection,
|
Selection: selection,
|
||||||
Stake: stake,
|
Stake: stake,
|
||||||
PlacedAt: existing.PlacedAt,
|
PlacedAt: existing.PlacedAt,
|
||||||
Outcome: BetOutcome.Pending,
|
Outcome: regrade ? BetOutcome.Pending : existing.Outcome,
|
||||||
Notes: notes);
|
Notes: notes);
|
||||||
|
|
||||||
var result = await _results.GetAsync(eventId, ct).ConfigureAwait(false);
|
if (regrade)
|
||||||
if (result is not null)
|
|
||||||
{
|
{
|
||||||
var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(toPersist.Selection, result);
|
var result = await _results.GetAsync(eventId, ct).ConfigureAwait(false);
|
||||||
if (graded is not null)
|
if (result is not null)
|
||||||
toPersist = toPersist.WithOutcome(graded.Value);
|
{
|
||||||
|
var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(toPersist.Selection, result);
|
||||||
|
if (graded is not null)
|
||||||
|
toPersist = toPersist.WithOutcome(graded.Value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _bets.UpdateAsync(toPersist, ct).ConfigureAwait(false);
|
await _bets.UpdateAsync(toPersist, ct).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -38,4 +38,32 @@ public sealed class CsvTests
|
|||||||
"Kelly,ok\r\n" +
|
"Kelly,ok\r\n" +
|
||||||
"\"Flat, fixed\",\"say \"\"hi\"\"\"\r\n");
|
"\"Flat, fixed\",\"say \"\"hi\"\"\"\r\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("=cmd|'/c calc'!A1")]
|
||||||
|
[InlineData("+1+1")]
|
||||||
|
[InlineData("-2+3")]
|
||||||
|
[InlineData("@SUM(A1)")]
|
||||||
|
[InlineData("\ttab")]
|
||||||
|
[InlineData("\rcr")]
|
||||||
|
public void NeutralizeFormula_PrefixesLeadingFormulaTriggers(string dangerous)
|
||||||
|
{
|
||||||
|
Csv.NeutralizeFormula(dangerous).Should().Be("'" + dangerous);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Home vs Away")]
|
||||||
|
[InlineData("normal note")]
|
||||||
|
[InlineData("3-1 win")]
|
||||||
|
[InlineData("")]
|
||||||
|
public void NeutralizeFormula_LeavesSafeValuesUntouched(string safe)
|
||||||
|
{
|
||||||
|
Csv.NeutralizeFormula(safe).Should().Be(safe);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NeutralizeFormula_Null_IsEmpty()
|
||||||
|
{
|
||||||
|
Csv.NeutralizeFormula(null).Should().BeEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,27 @@ public sealed class ExportToCsvUseCaseTests : IDisposable
|
|||||||
content.Should().Contain("evt-1");
|
content.Should().Contain("evt-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExportJournalAsync_NeutralizesFormulaInjection_InNotes()
|
||||||
|
{
|
||||||
|
var bet = new PlacedBet(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
new EventId("evt-1"),
|
||||||
|
new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.0m)),
|
||||||
|
100m, T0, BetOutcome.Pending, "=cmd|'/c calc'!A1");
|
||||||
|
_bets.ListAsync(Arg.Any<CancellationToken>()).Returns(new[] { bet });
|
||||||
|
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new Dictionary<EventId, Event>());
|
||||||
|
|
||||||
|
var path = await CreateSut().ExportJournalAsync();
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(path!);
|
||||||
|
// The dangerous note is apostrophe-prefixed so no cell opens as a live formula in
|
||||||
|
// Excel; it sits in the last column, preceded by the CSV separator.
|
||||||
|
content.Should().Contain(",'=cmd");
|
||||||
|
content.Should().NotContain(",=cmd");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ExportPaperLedgerAsync_ReturnsNull_When_NoBets()
|
public async Task ExportPaperLedgerAsync_ReturnsNull_When_NoBets()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,4 +82,40 @@ public sealed class UpdatePlacedBetUseCaseTests
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
await _bets.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
await _bets.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotesOnlyEdit_PreservesSettledOutcome_EvenWhenResultPruned()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var eid = new EventId("evt-1");
|
||||||
|
var sameSelection = Selection(side: Side.Side1, rate: 2.0m);
|
||||||
|
var settledWon = new PlacedBet(id, eid, sameSelection, 50m, PlacedAt, BetOutcome.Won, "old");
|
||||||
|
_bets.GetAsync(id, Arg.Any<CancellationToken>()).Returns(settledWon);
|
||||||
|
_events.GetAsync(eid, Arg.Any<CancellationToken>()).Returns(Ev(eid));
|
||||||
|
// Result row has been pruned by retention.
|
||||||
|
_results.GetAsync(eid, Arg.Any<CancellationToken>()).Returns((EventResult?)null);
|
||||||
|
|
||||||
|
// Same selection/event — only the note changes.
|
||||||
|
var updated = await CreateSut().ExecuteAsync(id, eid, sameSelection, stake: 50m, notes: "new note");
|
||||||
|
|
||||||
|
updated.Outcome.Should().Be(BetOutcome.Won); // NOT silently reset to Pending
|
||||||
|
updated.Notes.Should().Be("new note");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangedSelection_WithNoResult_ResetsToPending()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var eid = new EventId("evt-1");
|
||||||
|
var settledWon = new PlacedBet(id, eid, Selection(side: Side.Side1, rate: 2.0m), 50m, PlacedAt, BetOutcome.Won, "old");
|
||||||
|
_bets.GetAsync(id, Arg.Any<CancellationToken>()).Returns(settledWon);
|
||||||
|
_events.GetAsync(eid, Arg.Any<CancellationToken>()).Returns(Ev(eid));
|
||||||
|
_results.GetAsync(eid, Arg.Any<CancellationToken>()).Returns((EventResult?)null);
|
||||||
|
|
||||||
|
// Selection changed (Side1 → Side2) but no result to grade against → must be Pending,
|
||||||
|
// never the now-stale Won.
|
||||||
|
var updated = await CreateSut().ExecuteAsync(id, eid, Selection(side: Side.Side2, rate: 3.0m), stake: 50m, notes: "old");
|
||||||
|
|
||||||
|
updated.Outcome.Should().Be(BetOutcome.Pending);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user