Gonçalo Rodrigues 91796c9fb9 test(finance): expand unit test coverage from ~55% to 64.7% (#34)
* infra(terraform): manage finance session secret via random_password

Replace the hand-rolled variable (with insecure hardcoded default) with a
random_password resource so Terraform auto-generates a 48-char secret and
owns the finance-api-secrets k8s Secret lifecycle.

To rotate: terraform taint random_password.finance_session_secret && terraform apply

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(finance): active sessions panel + account deletion with full data purge

Sessions panel (/account):
- AuthSession now stores IPAddress and Device (browser + OS hint)
  populated from X-Forwarded-For / User-Agent on every login
- Lists all active sessions with device icon, IP, sign-in time
- Current session badge ("This device") — cannot be self-revoked
- DELETE /sessions/:id revokes any other session (user-scoped)

Account deletion (POST /account/delete):
- Password accounts require password confirmation
- OAuth accounts require typing email address to confirm
- deleteAllUserData purges all 12 finance collections + user record
  in a single call: accounts, categories, transactions, trades,
  ticker_mappings, goals, import_schedules, properties, loans,
  permissions, households, sessions → then the user itself
- Clears session cookie and redirects to login with success message

Infrastructure:
- findAuthUserByID added to store + storeIface
- getSessionsByUserID, deleteSessionForUser added to store + storeIface
- contains() added to template FuncMap
- accountTmpl registered; GET /account, POST /account/delete,
  DELETE /sessions/:id routes wired
- 🔐 nav icon links to /account page
- Full EN + PT i18n coverage for all new strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(finance): expand unit test coverage from ~55% to 64.7%

- Add handler_coverage_test.go (~3300 lines) covering auth flows,
  org request lifecycle, CSV bank import, property/loan views,
  fiscal year operations, session management, and cross-handler
  consistency (values shown on one page match actions on others)
- Add handler_org_test.go (~1800 lines) covering the full org
  handler surface: teams, members, invites, events, budget lines,
  tx requests (all status transitions), ledger, analysis, and reports
- Extend handler_test.go mockStore with: properties/loans slice fields,
  authUsers map with session-aware lookup, household field, org maps,
  and updateFiscalYearStatusErr for error-path testing
- Fix nav bar: Business and Account links now show active state and
  use i18n keys (removes hardcoded emoji); add account key to en/pt locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:07:29 +01:00

161 lines
4.5 KiB
Go

package main
import (
"context"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func (s *Store) createAuthUser(ctx context.Context, u *AuthUser) error {
u.ID = bson.NewObjectID()
u.CreatedAt = time.Now()
_, err := s.db.Collection("finance_users").InsertOne(ctx, u)
return err
}
func (s *Store) findAuthUserByEmail(ctx context.Context, email string) (*AuthUser, error) {
var u AuthUser
err := s.db.Collection("finance_users").FindOne(ctx, bson.M{"email": email}).Decode(&u)
if err == mongo.ErrNoDocuments {
return nil, nil
}
return &u, err
}
func (s *Store) findAuthUserByID(ctx context.Context, userID string) (*AuthUser, error) {
oid, err := bson.ObjectIDFromHex(userID)
if err != nil {
return nil, nil
}
var u AuthUser
err = s.db.Collection("finance_users").FindOne(ctx, bson.M{"_id": oid}).Decode(&u)
if err == mongo.ErrNoDocuments {
return nil, nil
}
return &u, err
}
func (s *Store) findAuthUserByProvider(ctx context.Context, provider, providerID string) (*AuthUser, error) {
var u AuthUser
err := s.db.Collection("finance_users").FindOne(ctx, bson.M{
"provider": provider,
"provider_id": providerID,
}).Decode(&u)
if err == mongo.ErrNoDocuments {
return nil, nil
}
return &u, err
}
func (s *Store) createAuthSession(ctx context.Context, sess *AuthSession) error {
sess.ID = bson.NewObjectID()
sess.CreatedAt = time.Now()
_, err := s.db.Collection("finance_sessions").InsertOne(ctx, sess)
return err
}
func (s *Store) getAuthSession(ctx context.Context, id string) (*AuthSession, error) {
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, nil
}
var sess AuthSession
err = s.db.Collection("finance_sessions").FindOne(ctx, bson.M{"_id": oid}).Decode(&sess)
if err == mongo.ErrNoDocuments {
return nil, nil
}
return &sess, err
}
func (s *Store) deleteAuthSession(ctx context.Context, id string) error {
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil
}
_, err = s.db.Collection("finance_sessions").DeleteOne(ctx, bson.M{"_id": oid})
return err
}
func (s *Store) getSessionsByUserID(ctx context.Context, userID string) ([]AuthSession, error) {
oid, err := bson.ObjectIDFromHex(userID)
if err != nil {
return nil, nil
}
cur, err := s.db.Collection("finance_sessions").Find(ctx,
bson.M{"user_id": oid},
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}),
)
if err != nil {
return nil, err
}
var sessions []AuthSession
if err := cur.All(ctx, &sessions); err != nil {
return nil, err
}
return sessions, nil
}
func (s *Store) deleteSessionForUser(ctx context.Context, sessionID, userID string) error {
sid, err := bson.ObjectIDFromHex(sessionID)
if err != nil {
return nil
}
uid, err := bson.ObjectIDFromHex(userID)
if err != nil {
return nil
}
_, err = s.db.Collection("finance_sessions").DeleteOne(ctx, bson.M{"_id": sid, "user_id": uid})
return err
}
// deleteAllUserData purges every record belonging to userID across all collections,
// then deletes the user account itself. Irreversible.
func (s *Store) deleteAllUserData(ctx context.Context, userID string) error {
uid, err := bson.ObjectIDFromHex(userID)
if err != nil {
return err
}
filter := bson.M{"user_id": uid}
orFilter := bson.M{"$or": bson.A{bson.M{"owner_id": uid}, bson.M{"partner_id": uid}}}
orFilterPerms := bson.M{"$or": bson.A{bson.M{"owner_id": uid}, bson.M{"viewer_id": uid}}}
collections := []struct {
name string
filter interface{}
}{
{"finance_accounts", filter},
{"finance_categories", filter},
{"finance_transactions", filter},
{"finance_trades", filter},
{"finance_ticker_mappings", filter},
{"finance_goals", filter},
{"finance_import_schedules", filter},
{"finance_properties", filter},
{"finance_loans", filter},
{"finance_permissions", orFilterPerms},
{"finance_households", orFilter},
{"finance_sessions", bson.M{"user_id": uid}},
}
for _, c := range collections {
if _, err := s.db.Collection(c.name).DeleteMany(ctx, c.filter); err != nil {
return err
}
}
_, err = s.db.Collection("finance_users").DeleteOne(ctx, bson.M{"_id": uid})
return err
}
func (s *Store) ensureAuthIndexes(ctx context.Context) {
s.db.Collection("finance_users").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "email", Value: 1}},
Options: options.Index().SetUnique(true).SetSparse(true),
})
s.db.Collection("finance_sessions").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "expires_at", Value: 1}},
Options: options.Index().SetExpireAfterSeconds(0),
})
}