Stage 7 · Master
Phase 7 — Resident Service
Testing
Testing resident-service like an attacker, not just a happy-path user
The Adversarial Test Matrix
Ownership shares, occupancy exclusion, and cross-tenant isolation are exactly the kind of rules a well-behaved client never violates but a malicious or buggy one will. This lesson builds a table-driven test suite where every case is an attempt to break an invariant, against a real Postgres via Testcontainers — the goal is proving each constraint actually rejects the attack, not just that the happy path works.
- Read another tenant's resident by guessing a valid UUID
- Add a household member using a resident_id from a different tenant
- Create an ownership share that pushes a flat's total past 100%
- Create two overlapping tenant occupancies for the same flat
- Create an occupancy referencing a flat_id from a different tenant (via a stubbed FlatClient)
- Reactivate a deactivated resident into a colliding active email
Table-Driven Adversarial Tests
//go:build integration
package postgres_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/hoa-platform/backend/services/resident-service/internal/domain"
pgrepo "github.com/hoa-platform/backend/services/resident-service/internal/repository/postgres"
)
func TestAdversarial_CrossTenantResidentRead(t *testing.T) {
pool := newTestPool(t) // starts Testcontainers Postgres, applies migrations 0001–0004
repo := pgrepo.NewResidentRepository(pool)
tenantA, tenantB := uuid.New(), uuid.New()
created, err := repo.Create(context.Background(), domain.Resident{
TenantID: tenantA, FullName: "Asha Rao", Email: "asha@example.com", Phone: "+91-9000000000",
GovernmentIDLast4: "1234", GovernmentIDHash: "hash-a",
})
if err != nil {
t.Fatalf("setup create failed: %v", err)
}
_, err = repo.Get(context.Background(), tenantB, created.ID)
if err != domain.ErrResidentNotFound {
t.Fatalf("cross-tenant read must return ErrResidentNotFound, got %v", err)
}
}
func TestAdversarial_OwnershipSharesCannotExceed100(t *testing.T) {
pool := newTestPool(t)
ownerships := pgrepo.NewOwnershipRepository(pool)
tenantID, flatID := uuid.New(), uuid.New()
residentA := seedResident(t, pool, tenantID)
residentB := seedResident(t, pool, tenantID)
_, err := ownerships.CreateShare(context.Background(), domain.FlatOwnership{
TenantID: tenantID, FlatID: flatID, ResidentID: residentA,
SharePercentage: 70, DeedReference: "DEED-A", EffectiveFrom: fixedDate(t, "2024-01-01"),
})
if err != nil {
t.Fatalf("first 70%% share should succeed: %v", err)
}
_, err = ownerships.CreateShare(context.Background(), domain.FlatOwnership{
TenantID: tenantID, FlatID: flatID, ResidentID: residentB,
SharePercentage: 40, DeedReference: "DEED-B", EffectiveFrom: fixedDate(t, "2024-01-01"),
})
if err != domain.ErrOwnershipSharesExceed100 {
t.Fatalf("70%% + 40%% must be rejected, got %v", err)
}
}
func TestAdversarial_OverlappingTenantOccupancyRejected(t *testing.T) {
pool := newTestPool(t)
occupancies := pgrepo.NewOccupancyRepository(pool)
tenantID, flatID := uuid.New(), uuid.New()
residentA := seedResident(t, pool, tenantID)
residentB := seedResident(t, pool, tenantID)
_, err := occupancies.MoveIn(context.Background(), domain.Occupancy{
TenantID: tenantID, FlatID: flatID, ResidentID: residentA,
OccupancyType: domain.OccupancyTenant, MoveInDate: fixedDate(t, "2024-02-01"),
})
if err != nil {
t.Fatalf("first move-in should succeed: %v", err)
}
_, err = occupancies.MoveIn(context.Background(), domain.Occupancy{
TenantID: tenantID, FlatID: flatID, ResidentID: residentB,
OccupancyType: domain.OccupancyTenant, MoveInDate: fixedDate(t, "2024-03-01"),
})
if err != domain.ErrOverlappingTenancy {
t.Fatalf("overlapping tenant occupancy must be rejected, got %v", err)
}
}
func TestAdversarial_CrossTenantFlatIDRejectedByStubbedFlatClient(t *testing.T) {
// This test exercises the service layer, not the repository, because
// the cross-service check happens in OwnershipService before
// CreateShare is ever called. A stub simulates flat-service returning
// 404 for a flat_id that belongs to another tenant.
stub := stubFlatClient{existsResult: false}
svc := newOwnershipServiceWithStub(t, stub)
_, err := svc.CreateShare(context.Background(), domain.FlatOwnership{
TenantID: uuid.New(), FlatID: uuid.New(), ResidentID: uuid.New(),
SharePercentage: 100, DeedReference: "DEED-X",
})
if err != domain.ErrFlatNotFoundForOwnership {
t.Fatalf("cross-tenant flat_id must be rejected before any insert, got %v", err)
}
}Each test targets one specific constraint or check by name — a failing test here names exactly which invariant regressed, not just 'something broke.'
Running the Suite Locally
Applied exercise
Add the concurrent double-share adversarial test
TestAdversarial_OwnershipSharesCannotExceed100 proves sequential rejection, but the row-locking behavior from the Ownership lesson specifically defends against two concurrent requests, which the current suite never actually exercises concurrently.
- Write a test that launches two goroutines, each calling CreateShare with a 60% share for the same flat_id at the same time.
- Assert exactly one goroutine succeeds and the other receives ErrOwnershipSharesExceed100.
- Run it under
go test -raceand confirm no data race is reported in addition to the correctness assertion.
Deliverable
A new concurrent test using sync.WaitGroup and two goroutines, passing cleanly under -race.
Completion checks
- The test fails if the FOR UPDATE lock is temporarily removed from CreateShare (verify this manually once).
- Exactly one of the two concurrent calls succeeds, never both, never neither.
- go test -race reports no races.
Why does TestAdversarial_CrossTenantFlatIDRejectedByStubbedFlatClient use a stub instead of a real flat-service instance?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.