Skip to content
Open
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
1 change: 1 addition & 0 deletions backend/Hookline.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@
<Project Path="tests/Hookline.Infrastructure.Tests/Hookline.Infrastructure.Tests.csproj" />
<Project Path="tests/Hookline.Modules.YouTubeUploads.Tests/Hookline.Modules.YouTubeUploads.Tests.csproj" />
<Project Path="tests/Hookline.Modules.YouTubeComments.Tests/Hookline.Modules.YouTubeComments.Tests.csproj" />
<Project Path="tests/Hookline.SharedKernel.Tests/Hookline.SharedKernel.Tests.csproj" />
</Folder>
</Solution>
60 changes: 60 additions & 0 deletions backend/tests/Hookline.Infrastructure.Tests/HooklineUserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using Hookline.Infrastructure.Auth;
using Hookline.SharedKernel.Auth;

namespace Hookline.Infrastructure.Tests;

public sealed class HooklineUserTests
{
[Fact]
public void Anonymous_is_not_authenticated()
{
Assert.False(HooklineUser.Anonymous.IsAuthenticated);
Assert.Null(HooklineUser.Anonymous.UserId);
Assert.Null(HooklineUser.Anonymous.Role);
Assert.False(HooklineUser.Anonymous.IsSystem);
}

[Fact]
public void System_is_authenticated_with_owner_role()
{
Assert.True(HooklineUser.System.IsAuthenticated);
Assert.Equal(UserRole.Owner, HooklineUser.System.Role);
Assert.True(HooklineUser.System.IsSystem);
}

[Fact]
public void Authenticated_factory_sets_all_fields()
{
var id = Guid.NewGuid();
var user = HooklineUser.Authenticated(id, "user@example.com", UserRole.Admin);

Assert.True(user.IsAuthenticated);
Assert.Equal(id, user.UserId);
Assert.Equal("user@example.com", user.Email);
Assert.Equal(UserRole.Admin, user.Role);
Assert.False(user.IsSystem);
}

[Theory]
[InlineData(UserRole.Owner, UserRole.Owner, true)]
[InlineData(UserRole.Owner, UserRole.Admin, true)]
[InlineData(UserRole.Owner, UserRole.Member, true)]
[InlineData(UserRole.Admin, UserRole.Admin, true)]
[InlineData(UserRole.Admin, UserRole.Member, true)]
[InlineData(UserRole.Admin, UserRole.Owner, false)]
[InlineData(UserRole.Member, UserRole.Member, true)]
[InlineData(UserRole.Member, UserRole.Admin, false)]
[InlineData(UserRole.Member, UserRole.Owner, false)]
public void HasAtLeast_compares_role_hierarchy(UserRole actual, UserRole required, bool expected)
{
var user = HooklineUser.Authenticated(Guid.NewGuid(), null, actual);

Assert.Equal(expected, user.HasAtLeast(required));
}

[Fact]
public void HasAtLeast_returns_false_for_anonymous()
{
Assert.False(HooklineUser.Anonymous.HasAtLeast(UserRole.Member));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using Hookline.Infrastructure.Auth;
using Hookline.SharedKernel.Auth;

namespace Hookline.Infrastructure.Tests;

public sealed class IdentityTokenServiceTests
{
private const string Key = "test-signing-key-at-least-32-bytes-long!!";
private readonly IdentityTokenService _svc = new(Key);

[Fact]
public void Constructor_throws_when_signing_key_is_null()
{
Assert.Throws<InvalidOperationException>(() => new IdentityTokenService(null));
}

[Fact]
public void Constructor_throws_when_signing_key_is_empty()
{
Assert.Throws<InvalidOperationException>(() => new IdentityTokenService(""));
}

[Fact]
public void Constructor_throws_when_signing_key_is_whitespace()
{
Assert.Throws<InvalidOperationException>(() => new IdentityTokenService(" "));
}

[Fact]
public void Sign_produces_two_part_token()
{
var token = _svc.Sign(Guid.NewGuid(), UserRole.Member, TimeSpan.FromMinutes(5));

Assert.Contains('.', token);
var parts = token.Split('.');
Assert.Equal(2, parts.Length);
Assert.NotEmpty(parts[0]);
Assert.NotEmpty(parts[1]);
}

[Fact]
public void Verify_roundtrips_user_and_role()
{
var userId = Guid.NewGuid();
var token = _svc.Sign(userId, UserRole.Admin, TimeSpan.FromMinutes(5));

var identity = _svc.Verify(token);

Assert.NotNull(identity);
Assert.Equal(userId, identity.UserId);
Assert.Equal(UserRole.Admin, identity.Role);
Assert.True(identity.ExpiresAt > DateTimeOffset.UtcNow);
}

[Fact]
public void Verify_returns_null_for_null_token()
{
Assert.Null(_svc.Verify(null));
}

[Fact]
public void Verify_returns_null_for_empty_token()
{
Assert.Null(_svc.Verify(""));
}

[Fact]
public void Verify_returns_null_when_no_dot()
{
Assert.Null(_svc.Verify("nodothere"));
}

[Fact]
public void Verify_returns_null_when_dot_at_start()
{
Assert.Null(_svc.Verify(".signature"));
}

[Fact]
public void Verify_returns_null_when_dot_at_end()
{
Assert.Null(_svc.Verify("payload."));
}

[Fact]
public void Verify_rejects_tampered_signature()
{
var token = _svc.Sign(Guid.NewGuid(), UserRole.Member, TimeSpan.FromMinutes(5));
var tampered = token[..^1] + (token[^1] == 'A' ? 'B' : 'A');

Assert.Null(_svc.Verify(tampered));
}

[Fact]
public void Verify_rejects_token_signed_with_different_key()
{
var other = new IdentityTokenService("a-completely-different-signing-key!!");
var token = other.Sign(Guid.NewGuid(), UserRole.Owner, TimeSpan.FromMinutes(5));

Assert.Null(_svc.Verify(token));
}

[Fact]
public void Verify_rejects_expired_token()
{
var token = _svc.Sign(Guid.NewGuid(), UserRole.Member, TimeSpan.FromSeconds(-1));

Assert.Null(_svc.Verify(token));
}
}
53 changes: 53 additions & 0 deletions backend/tests/Hookline.Infrastructure.Tests/PasswordHasherTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using Hookline.Infrastructure.Auth;

namespace Hookline.Infrastructure.Tests;

public sealed class PasswordHasherTests
{
private readonly PasswordHasher _hasher = new();

[Fact]
public void Hash_produces_bcrypt_format()
{
var hash = _hasher.Hash("password123");

Assert.StartsWith("$2", hash);
}

[Fact]
public void Hash_is_nondeterministic()
{
var h1 = _hasher.Hash("same");
var h2 = _hasher.Hash("same");

Assert.NotEqual(h1, h2);
}

[Fact]
public void Verify_returns_true_for_correct_password()
{
var hash = _hasher.Hash("secret");

Assert.True(_hasher.Verify("secret", hash));
}

[Fact]
public void Verify_returns_false_for_wrong_password()
{
var hash = _hasher.Hash("correct");

Assert.False(_hasher.Verify("wrong", hash));
}

[Fact]
public void Verify_returns_false_for_invalid_hash()
{
Assert.False(_hasher.Verify("anything", "not-a-bcrypt-hash"));
}

[Fact]
public void Verify_throws_for_empty_hash()
{
Assert.ThrowsAny<ArgumentException>(() => _hasher.Verify("anything", ""));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System.Net;

using Google;
using Google.Apis.Requests;

using Hookline.Modules.YouTubeComments.Infrastructure;

namespace Hookline.Modules.YouTubeComments.Tests;

public sealed class GoogleApiExceptionExtensionsTests
{
[Theory]
[InlineData(HttpStatusCode.InternalServerError, true)]
[InlineData(HttpStatusCode.BadGateway, true)]
[InlineData(HttpStatusCode.ServiceUnavailable, true)]
[InlineData(HttpStatusCode.GatewayTimeout, true)]
[InlineData(HttpStatusCode.TooManyRequests, true)]
[InlineData(HttpStatusCode.BadRequest, false)]
[InlineData(HttpStatusCode.Forbidden, false)]
[InlineData(HttpStatusCode.NotFound, false)]
[InlineData(HttpStatusCode.OK, false)]
public void IsTransientStatus_classifies_correctly(HttpStatusCode status, bool expected)
{
Assert.Equal(expected, GoogleApiExceptionExtensions.IsTransientStatus(status));
}

[Theory]
[InlineData(HttpStatusCode.InternalServerError, true)]
[InlineData(HttpStatusCode.TooManyRequests, true)]
[InlineData(HttpStatusCode.Forbidden, false)]
public void IsTransient_extension_uses_HttpStatusCode(HttpStatusCode status, bool expected)
{
var ex = new GoogleApiException("test") { HttpStatusCode = status };

Assert.Equal(expected, ex.IsTransient());
}

[Fact]
public void HasReason_returns_true_when_reason_matches()
{
var ex = new GoogleApiException("test")
{
Error = new RequestError
{
Errors = [new SingleError { Reason = "quotaExceeded" }]
}
};

Assert.True(ex.HasReason("quotaExceeded"));
}

[Fact]
public void HasReason_is_case_insensitive()
{
var ex = new GoogleApiException("test")
{
Error = new RequestError
{
Errors = [new SingleError { Reason = "QUOTAEXCEEDED" }]
}
};

Assert.True(ex.HasReason("quotaExceeded"));
}

[Fact]
public void HasReason_returns_false_when_no_match()
{
var ex = new GoogleApiException("test")
{
Error = new RequestError
{
Errors = [new SingleError { Reason = "notFound" }]
}
};

Assert.False(ex.HasReason("quotaExceeded"));
}

[Fact]
public void HasReason_returns_false_when_errors_null()
{
var ex = new GoogleApiException("test");

Assert.False(ex.HasReason("quotaExceeded"));
}

[Fact]
public void HasReason_returns_false_when_error_property_null()
{
var ex = new GoogleApiException("test") { Error = null };

Assert.False(ex.HasReason("quotaExceeded"));
}
}
Loading
Loading