package api_test import ( "encoding/json" "net/http" "testing" "atlas9.dev/c/core" "atlas9.dev/c/core/assert" "atlas9.dev/c/core/iam" "atlas9.dev/c/demo/api" "atlas9.dev/c/demo/store" ) // fetchPasswordResetToken pulls the most recent password reset task payload // from the queue. Mirrors fetchEmailVerificationToken. func fetchPasswordResetToken(t *testing.T, s *testServer) string { t.Helper() var payload string err := s.DB.QueryRowContext(t.Context(), `SELECT payload FROM password_reset_tasks ORDER BY id DESC LIMIT 1`, ).Scan(&payload) assert.Ok(t, err) var task store.PasswordResetTaskData assert.Ok(t, json.Unmarshal([]byte(payload), &task)) return task.Token } func TestAuditApi_RecordsTenantLifecycle(t *testing.T) { s := startServer(t) var created api.Tenants_CreateRes s.call(t, "/Tenants_Create", api.Tenants_CreateReq{Tenant: iam.Tenant{Name: "Acme"}}, &created) s.call(t, "/Tenants_Update", api.Tenants_UpdateReq{ Tenant: iam.Tenant{ID: created.Tenant.ID, Name: "Acme Corp"}, }, nil) var list api.Audit_ListRes res := s.call(t, "/Audit_List", api.Audit_ListReq{Tenant: created.Tenant.ID}, &list) assert.Eq(t, res.StatusCode, http.StatusOK) assert.Eq(t, len(list.Page.Items), 2) // Entries written within the same millisecond have no guaranteed ID // order, so match by action rather than position. var update, create iam.AuditEntry for _, e := range list.Page.Items { switch e.Action { case "Tenants_Update": update = e case "Tenants_Create": create = e } } assert.Eq(t, update.Action, "Tenants_Update") assert.Eq(t, update.Resource, created.Tenant.ID.String()) assert.Eq(t, update.Detail, "Acme Corp") assert.Eq(t, create.Action, "Tenants_Create") assert.Eq(t, create.Resource, created.Tenant.ID.String()) assert.Eq(t, create.Detail, "Acme") // Both entries carry the actor and a request ID for log correlation. assert.Eq(t, create.Actor == "", false) assert.Eq(t, create.Actor, update.Actor) assert.Eq(t, create.Request == "", false) assert.Eq(t, create.Time.IsZero(), false) } func TestAuditApi_RecordsInvitationAccept_InviteeActor(t *testing.T) { s := startServer(t) var tenant api.Tenants_CreateRes s.call(t, "/Tenants_Create", api.Tenants_CreateReq{Tenant: iam.Tenant{Name: "Acme"}}, &tenant) inviteeID := s.seedUser(t, "invitee@test.com", "pw") var inv api.TenantInvitations_CreateRes s.call(t, "/TenantInvitations_Create", api.TenantInvitations_CreateReq{ Tenant: tenant.Tenant.ID, Email: "invitee@test.com", }, &inv) s.login(t, "invitee@test.com", "pw") s.call(t, "/TenantInvitations_Accept", api.TenantInvitations_AcceptReq{Token: inv.Token}, nil) s.login(t, "default@test", "pass") var list api.Audit_ListRes s.call(t, "/Audit_List", api.Audit_ListReq{Tenant: tenant.Tenant.ID}, &list) // Accept (by the invitee), Create (by the owner), and Tenants_Create. assert.Eq(t, len(list.Page.Items), 3) var accept, create iam.AuditEntry for _, e := range list.Page.Items { switch e.Action { case "TenantInvitations_Accept": accept = e case "TenantInvitations_Create": create = e } } assert.Eq(t, accept.Resource, "invitee@test.com") assert.Eq(t, accept.Actor, inviteeID.String()) // The invitation was created by the owner, not the invitee. assert.Eq(t, create.Resource, "invitee@test.com") assert.Eq(t, create.Actor == accept.Actor, false) } // A failed mutation must not leave an audit entry: the append shares the // mutation's transaction, so a rollback discards both. func TestAuditApi_FailedMutationNotRecorded(t *testing.T) { s := startServer(t) var tenant api.Tenants_CreateRes s.call(t, "/Tenants_Create", api.Tenants_CreateReq{Tenant: iam.Tenant{Name: "Acme"}}, &tenant) s.call(t, "/TenantInvitations_Create", api.TenantInvitations_CreateReq{ Tenant: tenant.Tenant.ID, Email: "dup@test.com", }, nil) // Duplicate fails and rolls back. res := s.call(t, "/TenantInvitations_Create", api.TenantInvitations_CreateReq{ Tenant: tenant.Tenant.ID, Email: "dup@test.com", }, nil) assert.Eq(t, res.StatusCode, http.StatusInternalServerError) var list api.Audit_ListRes s.call(t, "/Audit_List", api.Audit_ListReq{Tenant: tenant.Tenant.ID}, &list) invites := 0 for _, e := range list.Page.Items { if e.Action == "TenantInvitations_Create" { invites++ } } assert.Eq(t, invites, 1) } func TestAuditApi_List_RejectsEmptyTenant(t *testing.T) { s := startServer(t) var body api.ErrorResponse res := s.call(t, "/Audit_List", api.Audit_ListReq{}, &body) assert.Eq(t, res.StatusCode, http.StatusBadRequest) assert.Eq(t, body.Message, iam.ErrTenantIDEmpty.Error()) } func TestAuditApi_List_WithoutCap_Forbidden(t *testing.T) { s := startServer(t) var tenant api.Tenants_CreateRes s.call(t, "/Tenants_Create", api.Tenants_CreateReq{Tenant: iam.Tenant{Name: "Acme"}}, &tenant) // A member with read access to the tenant, but no Audit.Read. s.seedUserInTenant(t, "scoped@test", "pw", tenant.Tenant.ID, iam.CapTenantsRead) s.login(t, "scoped@test", "pw") var body api.ErrorResponse res := s.call(t, "/Audit_List", api.Audit_ListReq{Tenant: tenant.Tenant.ID}, &body) assert.Eq(t, res.StatusCode, http.StatusForbidden) assert.Eq(t, body.Message, iam.ErrForbidden.Error()) } func TestAuditApi_List_CrossTenant_Forbidden(t *testing.T) { s := startServer(t) var a, b api.Tenants_CreateRes s.call(t, "/Tenants_Create", api.Tenants_CreateReq{Tenant: iam.Tenant{Name: "A"}}, &a) s.call(t, "/Tenants_Create", api.Tenants_CreateReq{Tenant: iam.Tenant{Name: "B"}}, &b) // Audit.Read scoped to tenant A only. s.seedUserInTenant(t, "scoped@test", "pw", a.Tenant.ID, iam.CapAuditRead) s.login(t, "scoped@test", "pw") var list api.Audit_ListRes res := s.call(t, "/Audit_List", api.Audit_ListReq{Tenant: a.Tenant.ID}, &list) assert.Eq(t, res.StatusCode, http.StatusOK) var body api.ErrorResponse res = s.call(t, "/Audit_List", api.Audit_ListReq{Tenant: b.Tenant.ID}, &body) assert.Eq(t, res.StatusCode, http.StatusForbidden) assert.Eq(t, body.Message, iam.ErrForbidden.Error()) } // The account/identity trail: registration, verification, logins (including // failures), logout, and password reset all appear in the user's own audit // log, readable by the user without any special grants. func TestAuditApi_UserTrail_IdentityLifecycle(t *testing.T) { s := startServer(t) s.logout(t) email, password := "trail@test", "pass" var reg api.Account_RegisterRes s.call(t, "/Account_Register", api.Account_RegisterReq{Email: email, Password: password}, ®) token := fetchEmailVerificationToken(t, s) s.call(t, "/Account_Verify", api.Account_VerifyReq{Token: token}, nil) s.login(t, email, password) s.logout(t) // Failed login: wrong password on an existing account. res := s.call(t, "/Identity_Login", api.Identity_LoginReq{Email: email, Password: "wrong"}, nil) assert.Eq(t, res.StatusCode, http.StatusInternalServerError) // Password reset via emailed token. s.call(t, "/Account_RequestPasswordReset", api.Account_RequestPasswordResetReq{Email: email}, nil) resetToken := fetchPasswordResetToken(t, s) s.call(t, "/Account_ResetPassword", api.Account_ResetPasswordReq{Token: resetToken, Password: "newpass"}, nil) s.login(t, email, "newpass") var list api.Audit_ListByUserRes httpRes := s.call(t, "/Audit_ListByUser", api.Audit_ListByUserReq{UserID: reg.UserID}, &list) assert.Eq(t, httpRes.StatusCode, http.StatusOK) counts := map[string]int{} for _, e := range list.Page.Items { counts[e.Action]++ assert.Eq(t, e.Subject, reg.UserID) } assert.Eq(t, counts["Account_Register"], 1) assert.Eq(t, counts["Account_Verify"], 1) assert.Eq(t, counts["Identity_Login"], 2) assert.Eq(t, counts["Identity_Logout"], 1) assert.Eq(t, counts["Identity_LoginFailed"], 1) assert.Eq(t, counts["Account_RequestPasswordReset"], 1) assert.Eq(t, counts["Account_ResetPassword"], 1) assert.Eq(t, len(list.Page.Items), 8) // The failure records why. for _, e := range list.Page.Items { if e.Action == "Identity_LoginFailed" { assert.Eq(t, e.Detail, "invalid password") } } } // Unknown emails leave no audit trail: there is no account to attach the // entry to, and recording them would let attackers probe for accounts. func TestAuditApi_UnknownEmailLogin_NotRecorded(t *testing.T) { s := startServer(t) s.logout(t) res := s.call(t, "/Identity_Login", api.Identity_LoginReq{Email: "ghost@test", Password: "x"}, nil) assert.Eq(t, res.StatusCode, http.StatusInternalServerError) var n int err := s.DB.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM audit_log WHERE action = 'Identity_LoginFailed'`).Scan(&n) assert.Ok(t, err) assert.Eq(t, n, 0) } func TestAuditApi_ListByUser_OtherUser_Forbidden(t *testing.T) { s := startServer(t) otherID := s.seedUser(t, "other@test", "pw") s.seedUser(t, "snoop@test", "pw") s.login(t, "snoop@test", "pw") var body api.ErrorResponse res := s.call(t, "/Audit_ListByUser", api.Audit_ListByUserReq{UserID: otherID}, &body) assert.Eq(t, res.StatusCode, http.StatusForbidden) assert.Eq(t, body.Message, iam.ErrForbidden.Error()) // The default principal holds system-scoped Audit.Read and may read // any user's trail. s.login(t, "default@test", "pass") var list api.Audit_ListByUserRes res = s.call(t, "/Audit_ListByUser", api.Audit_ListByUserReq{UserID: otherID}, &list) assert.Eq(t, res.StatusCode, http.StatusOK) } func TestAuditApi_ListByUser_RejectsEmptyUser(t *testing.T) { s := startServer(t) var body api.ErrorResponse res := s.call(t, "/Audit_ListByUser", api.Audit_ListByUserReq{}, &body) assert.Eq(t, res.StatusCode, http.StatusBadRequest) assert.Eq(t, body.Message, iam.ErrUserIDEmpty.Error()) } func TestAuditApi_List_Anonymous_Unauthorized(t *testing.T) { s := startServer(t) s.logout(t) var body api.ErrorResponse res := s.call(t, "/Audit_List", api.Audit_ListReq{Tenant: core.NewID("t")}, &body) assert.Eq(t, res.StatusCode, http.StatusUnauthorized) }