- Add backend services to create, list, and delete API tokens. - Implement Bearer token authentication to resolve tokens to users. - Register HTTP routes for managing user tokens under `/account/tokens`. - Add tests to verify that uploads with valid Bearer tokens associate the upload with the correct user, while invalid tokens fall back to anonymous uploads.
226 lines
7.1 KiB
Go
226 lines
7.1 KiB
Go
package services
|
|
|
|
import (
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestPasswordHashVerification(t *testing.T) {
|
|
hash := HashPassword("correct-horse")
|
|
if !VerifyPasswordHash(hash, "correct-horse") {
|
|
t.Fatalf("VerifyPasswordHash rejected the correct password")
|
|
}
|
|
if VerifyPasswordHash(hash, "wrong-password") {
|
|
t.Fatalf("VerifyPasswordHash accepted the wrong password")
|
|
}
|
|
}
|
|
|
|
func TestBootstrapCreatesAdminAndClosesRegistration(t *testing.T) {
|
|
auth := newTestAuthService(t)
|
|
available, err := auth.BootstrapAvailable()
|
|
if err != nil {
|
|
t.Fatalf("BootstrapAvailable returned error: %v", err)
|
|
}
|
|
if !available {
|
|
t.Fatalf("BootstrapAvailable = false, want true")
|
|
}
|
|
|
|
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
|
if err != nil {
|
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
|
}
|
|
if user.Role != UserRoleAdmin {
|
|
t.Fatalf("role = %q, want admin", user.Role)
|
|
}
|
|
|
|
if _, err := auth.CreateBootstrapUser("other", "other@example.test", "password123"); err == nil {
|
|
t.Fatalf("second bootstrap unexpectedly succeeded")
|
|
}
|
|
}
|
|
|
|
func TestLoginSessionAndDisabledUser(t *testing.T) {
|
|
auth := newTestAuthService(t)
|
|
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
|
if err != nil {
|
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
|
}
|
|
if _, _, err := auth.Login("daniel@example.test", "wrong"); err == nil {
|
|
t.Fatalf("Login accepted wrong password")
|
|
}
|
|
|
|
_, token, err := auth.Login("daniel@example.test", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Login returned error: %v", err)
|
|
}
|
|
sessionUser, _, err := auth.UserForSession(token)
|
|
if err != nil {
|
|
t.Fatalf("UserForSession returned error: %v", err)
|
|
}
|
|
if sessionUser.ID != user.ID {
|
|
t.Fatalf("session user = %q, want %q", sessionUser.ID, user.ID)
|
|
}
|
|
|
|
if err := auth.DisableUser(user.ID, true); err != nil {
|
|
t.Fatalf("DisableUser returned error: %v", err)
|
|
}
|
|
if _, _, err := auth.UserForSession(token); err == nil {
|
|
t.Fatalf("disabled user session still resolved")
|
|
}
|
|
}
|
|
|
|
func TestInviteAcceptsOnceAndResetChangesPassword(t *testing.T) {
|
|
auth := newTestAuthService(t)
|
|
admin, err := auth.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
|
if err != nil {
|
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
|
}
|
|
invite, err := auth.CreateInvite("friend@example.test", UserRoleUser, admin.ID, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("CreateInvite returned error: %v", err)
|
|
}
|
|
user, err := auth.AcceptInvite(invite.Token, "friend", "password123")
|
|
if err != nil {
|
|
t.Fatalf("AcceptInvite returned error: %v", err)
|
|
}
|
|
if user.Email != "friend@example.test" {
|
|
t.Fatalf("email = %q, want friend@example.test", user.Email)
|
|
}
|
|
if _, err := auth.AcceptInvite(invite.Token, "friend", "password123"); err == nil {
|
|
t.Fatalf("AcceptInvite allowed token reuse")
|
|
}
|
|
|
|
reset, err := auth.CreatePasswordResetInvite(user.ID, admin.ID)
|
|
if err != nil {
|
|
t.Fatalf("CreatePasswordResetInvite returned error: %v", err)
|
|
}
|
|
if _, err := auth.AcceptInvite(reset.Token, "", "newpassword123"); err != nil {
|
|
t.Fatalf("AcceptInvite reset returned error: %v", err)
|
|
}
|
|
if _, _, err := auth.Login("friend@example.test", "newpassword123"); err != nil {
|
|
t.Fatalf("Login with reset password returned error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAPITokenLifecycle(t *testing.T) {
|
|
auth := newTestAuthService(t)
|
|
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
|
if err != nil {
|
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
|
}
|
|
|
|
result, err := auth.CreateAPIToken(user.ID, "CLI laptop")
|
|
if err != nil {
|
|
t.Fatalf("CreateAPIToken returned error: %v", err)
|
|
}
|
|
if result.Plaintext == "" || !strings.HasPrefix(result.Plaintext, apiTokenPrefix) {
|
|
t.Fatalf("plaintext = %q, want %q prefix", result.Plaintext, apiTokenPrefix)
|
|
}
|
|
// The secret must never be stored in plaintext — only its hash.
|
|
if strings.Contains(result.Token.TokenHash, result.Plaintext) || result.Token.TokenHash == result.Plaintext {
|
|
t.Fatalf("stored token hash leaks the plaintext secret")
|
|
}
|
|
|
|
resolved, err := auth.UserForAPIToken(result.Plaintext)
|
|
if err != nil {
|
|
t.Fatalf("UserForAPIToken returned error: %v", err)
|
|
}
|
|
if resolved.ID != user.ID {
|
|
t.Fatalf("resolved user = %q, want %q", resolved.ID, user.ID)
|
|
}
|
|
|
|
tokens, err := auth.ListAPITokens(user.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListAPITokens returned error: %v", err)
|
|
}
|
|
if len(tokens) != 1 {
|
|
t.Fatalf("token count = %d, want 1", len(tokens))
|
|
}
|
|
if tokens[0].Name != "CLI laptop" {
|
|
t.Fatalf("token name = %q, want %q", tokens[0].Name, "CLI laptop")
|
|
}
|
|
if tokens[0].LastUsedAt == nil {
|
|
t.Fatalf("LastUsedAt not recorded after UserForAPIToken")
|
|
}
|
|
|
|
if _, err := auth.UserForAPIToken(result.Plaintext + "tampered"); err == nil {
|
|
t.Fatalf("UserForAPIToken accepted a tampered token")
|
|
}
|
|
if _, err := auth.UserForAPIToken("wbx_deadbeef.nope"); err == nil {
|
|
t.Fatalf("UserForAPIToken accepted an unknown token")
|
|
}
|
|
|
|
if err := auth.DeleteAPIToken(user.ID, tokens[0].ID); err != nil {
|
|
t.Fatalf("DeleteAPIToken returned error: %v", err)
|
|
}
|
|
if _, err := auth.UserForAPIToken(result.Plaintext); err == nil {
|
|
t.Fatalf("deleted token still resolved")
|
|
}
|
|
remaining, err := auth.ListAPITokens(user.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListAPITokens returned error: %v", err)
|
|
}
|
|
if len(remaining) != 0 {
|
|
t.Fatalf("token count after delete = %d, want 0", len(remaining))
|
|
}
|
|
}
|
|
|
|
func TestAPITokenScopedToOwnerAndDisabledUser(t *testing.T) {
|
|
auth := newTestAuthService(t)
|
|
owner, err := auth.CreateBootstrapUser("owner", "owner@example.test", "password123")
|
|
if err != nil {
|
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
|
}
|
|
invite, err := auth.CreateInvite("other@example.test", UserRoleUser, owner.ID, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("CreateInvite returned error: %v", err)
|
|
}
|
|
other, err := auth.AcceptInvite(invite.Token, "other", "password123")
|
|
if err != nil {
|
|
t.Fatalf("AcceptInvite returned error: %v", err)
|
|
}
|
|
|
|
result, err := auth.CreateAPIToken(owner.ID, "owner token")
|
|
if err != nil {
|
|
t.Fatalf("CreateAPIToken returned error: %v", err)
|
|
}
|
|
tokens, err := auth.ListAPITokens(owner.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListAPITokens returned error: %v", err)
|
|
}
|
|
|
|
// Another user cannot delete tokens they do not own.
|
|
if err := auth.DeleteAPIToken(other.ID, tokens[0].ID); err == nil {
|
|
t.Fatalf("DeleteAPIToken allowed deletion across users")
|
|
}
|
|
|
|
// A disabled owner cannot authenticate with their token.
|
|
if err := auth.DisableUser(owner.ID, true); err != nil {
|
|
t.Fatalf("DisableUser returned error: %v", err)
|
|
}
|
|
if _, err := auth.UserForAPIToken(result.Plaintext); err == nil {
|
|
t.Fatalf("disabled user token still resolved")
|
|
}
|
|
}
|
|
|
|
func newTestAuthService(t *testing.T) *AuthService {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
|
|
if err != nil {
|
|
t.Fatalf("NewUploadService returned error: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := upload.Close(); err != nil {
|
|
t.Fatalf("Close returned error: %v", err)
|
|
}
|
|
})
|
|
auth, err := NewAuthService(upload.DB(), "http://example.test")
|
|
if err != nil {
|
|
t.Fatalf("NewAuthService returned error: %v", err)
|
|
}
|
|
return auth
|
|
}
|