feat(accounts): implement user accounts, sessions, and dashboards
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
Introduce Stage 4 features to support multi-user accounts, cookie-based web sessions, and personal dashboards. Changes include: - Adding `/register` to bootstrap the first admin account and `/login`/`/logout` for session management. - Creating a personal dashboard (`/app`) to display owned boxes, storage usage, and upload history. - Implementing admin user management (`/admin/users`) for generating invite links and managing user states. - Updating the bbolt database schema to store users, sessions, invites, and collections. - Adding `golang.org/x/crypto` for password hashing and introducing unit tests for account handlers.
This commit is contained in:
165
backend/libs/handlers/accounts_test.go
Normal file
165
backend/libs/handlers/accounts_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
func TestLoggedInUploadStoresOwnerAndAnonymousUploadDoesNot(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
user, err := app.authService.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
_, token, err := app.authService.Login("daniel@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login returned error: %v", err)
|
||||
}
|
||||
|
||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||
response := httptest.NewRecorder()
|
||||
app.Upload(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("owned upload status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
var ownedPayload services.UploadResult
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &ownedPayload); err != nil {
|
||||
t.Fatalf("json.Unmarshal owned returned error: %v", err)
|
||||
}
|
||||
ownedBox, err := app.uploadService.GetBox(ownedPayload.BoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBox owned returned error: %v", err)
|
||||
}
|
||||
if ownedBox.OwnerID != user.ID {
|
||||
t.Fatalf("owned OwnerID = %q, want %q", ownedBox.OwnerID, user.ID)
|
||||
}
|
||||
|
||||
owned := uploadThroughApp(t, app)
|
||||
anonymous, err := app.uploadService.GetBox(owned.BoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBox anonymous returned error: %v", err)
|
||||
}
|
||||
if anonymous.OwnerID != "" {
|
||||
t.Fatalf("anonymous OwnerID = %q, want empty", anonymous.OwnerID)
|
||||
}
|
||||
|
||||
boxes, err := app.uploadService.ListBoxes(0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListBoxes returned error: %v", err)
|
||||
}
|
||||
foundOwned := false
|
||||
for _, box := range boxes {
|
||||
if box.OwnerID == user.ID {
|
||||
foundOwned = true
|
||||
}
|
||||
}
|
||||
if !foundOwned {
|
||||
t.Fatalf("logged-in upload did not store owner id %q", user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHandlerCreatesUserAndMarksInviteUsed(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
admin, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
invite, err := app.authService.CreateInvite("friend@example.test", services.UserRoleUser, admin.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInvite returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/invite/"+invite.Token, strings.NewReader("username=friend&password=password123"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.SetPathValue("token", invite.Token)
|
||||
response := httptest.NewRecorder()
|
||||
app.InvitePost(response, request)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("InvitePost status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if _, err := app.authService.AcceptInvite(invite.Token, "friend", "password123"); err == nil {
|
||||
t.Fatalf("invite token remained reusable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonOwnerCannotManageOwnedBox(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
owner, err := app.authService.CreateBootstrapUser("owner", "owner@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
invite, err := app.authService.CreateInvite("other@example.test", services.UserRoleUser, owner.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInvite returned error: %v", err)
|
||||
}
|
||||
other, err := app.authService.AcceptInvite(invite.Token, "other", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptInvite returned error: %v", err)
|
||||
}
|
||||
|
||||
result := createOwnedBoxThroughApp(t, app, owner.ID)
|
||||
if err := app.uploadService.RenameOwnedBox(result.BoxID, other.ID, "stolen"); err == nil {
|
||||
t.Fatalf("RenameOwnedBox allowed non-owner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUploadBypassesMaxUploadSize(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
_, token, err := app.authService.Login("admin@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login returned error: %v", err)
|
||||
}
|
||||
|
||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", int(app.uploadService.MaxUploadSize())+1))
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||
response := httptest.NewRecorder()
|
||||
app.Upload(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("admin upload status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
|
||||
t.Helper()
|
||||
user, err := app.authService.UserByID(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("UserByID returned error: %v", err)
|
||||
}
|
||||
_, token, err := app.authService.Login(user.Email, "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login returned error: %v", err)
|
||||
}
|
||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||
response := httptest.NewRecorder()
|
||||
app.Upload(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("upload status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
var payload services.UploadResult
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
Reference in New Issue
Block a user