package api_test import ( "encoding/json" "net/http" "strings" "testing" "atlas9.dev/c/core/assert" "atlas9.dev/c/core/iam" "atlas9.dev/c/demo/api" "atlas9.dev/c/demo/store" ) // fetchTenantInvitationTaskPayload pulls the most recent invitation task // payload from the queue. Mirrors fetchEmailVerificationToken. func fetchTenantInvitationTaskPayload(t *testing.T, s *testServer) store.TenantInvitationTaskPayload { t.Helper() var payload string err := s.DB.QueryRowContext(t.Context(), `SELECT payload FROM tenant_invitation_tasks ORDER BY id DESC LIMIT 1`, ).Scan(&payload) assert.Ok(t, err) var p store.TenantInvitationTaskPayload assert.Ok(t, json.Unmarshal([]byte(payload), &p)) return p } func TestTenantInvitationsApi_Create_EnqueuesEmailTask(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) var inv api.TenantInvitations_CreateRes res := s.call(t, "/TenantInvitations.Create", api.TenantInvitations_CreateReq{ Tenant: tenant.Tenant.ID, Email: "invitee@test.com", }, &inv) assert.Eq(t, res.StatusCode, http.StatusOK) if _, _, ok := strings.Cut(inv.Token, "."); !ok { t.Fatalf("token missing dot separator: %q", inv.Token) } payload := fetchTenantInvitationTaskPayload(t, s) assert.Eq(t, payload.Email, "invitee@test.com") assert.Eq(t, payload.Token, inv.Token) } func TestTenantInvitationsApi_Accept_ProducesMembership(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) // Seed the invitee with a matching email and log them in. inviteeID := s.seedUser(t, "invitee@test.com", "pw") // Owner sends invitation (still logged in as default principal). var inv api.TenantInvitations_CreateRes s.call(t, "/TenantInvitations.Create", api.TenantInvitations_CreateReq{ Tenant: tenant.Tenant.ID, Email: "invitee@test.com", }, &inv) // Log in as invitee and accept. s.login(t, "invitee@test.com", "pw") acceptRes := s.call(t, "/TenantInvitations.Accept", api.TenantInvitations_AcceptReq{Token: inv.Token}, nil) assert.Eq(t, acceptRes.StatusCode, http.StatusOK) // Verify membership was created (via the default principal who has caps). s.login(t, "default@test", "pass") var got iam.TenantMember s.call(t, "/TenantMembers.Get", api.TenantMembers_GetReq{ Tenant: tenant.Tenant.ID, UserID: inviteeID, }, &got) assert.Eq(t, got.UserID, inviteeID) assert.Eq(t, got.Owner, false) } func TestTenantInvitationsApi_Accept_TokenConsumedOnce(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.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) // Replay — token should no longer resolve. var body api.ErrorResponse res := s.call(t, "/TenantInvitations.Accept", api.TenantInvitations_AcceptReq{Token: inv.Token}, &body) assert.Eq(t, res.StatusCode, http.StatusNotFound) } func TestTenantInvitationsApi_Accept_TamperedTokenRejected(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.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) key, _, _ := strings.Cut(inv.Token, ".") tampered := key + ".definitely-not-the-code" s.login(t, "invitee@test.com", "pw") var body api.ErrorResponse res := s.call(t, "/TenantInvitations.Accept", api.TenantInvitations_AcceptReq{Token: tampered}, &body) assert.Eq(t, res.StatusCode, http.StatusNotFound) } func TestTenantInvitationsApi_Accept_WrongEmailForbidden(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) // Create invitation for one email but log in as a different user. s.seedUser(t, "other@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, "other@test.com", "pw") var body api.ErrorResponse res := s.call(t, "/TenantInvitations.Accept", api.TenantInvitations_AcceptReq{Token: inv.Token}, &body) assert.Eq(t, res.StatusCode, http.StatusForbidden) } func TestTenantInvitationsApi_Accept_EmailNormalizationMatches(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) // User stored with lowercase, invitation issued with mixed case. s.seedUser(t, "mixed@test.com", "pw") var inv api.TenantInvitations_CreateRes s.call(t, "/TenantInvitations.Create", api.TenantInvitations_CreateReq{ Tenant: tenant.Tenant.ID, Email: "Mixed@Test.Com", }, &inv) s.login(t, "mixed@test.com", "pw") res := s.call(t, "/TenantInvitations.Accept", api.TenantInvitations_AcceptReq{Token: inv.Token}, nil) assert.Eq(t, res.StatusCode, http.StatusOK) } func TestTenantInvitationsApi_Accept_RescindedInvite(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.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) // Owner rescinds. s.call(t, "/TenantInvitations.Delete", api.TenantInvitations_DeleteReq{ Tenant: tenant.Tenant.ID, Email: "invitee@test.com", }, nil) // Invitee's token no longer resolves. s.login(t, "invitee@test.com", "pw") var body api.ErrorResponse res := s.call(t, "/TenantInvitations.Accept", api.TenantInvitations_AcceptReq{Token: inv.Token}, &body) assert.Eq(t, res.StatusCode, http.StatusNotFound) } func TestTenantInvitationsApi_Decline_RemovesInvite(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") res := s.call(t, "/TenantInvitations.Decline", api.TenantInvitations_DeclineReq{Token: inv.Token}, nil) assert.Eq(t, res.StatusCode, http.StatusOK) // Invitee is NOT a member. s.login(t, "default@test", "pass") var body api.ErrorResponse getRes := s.call(t, "/TenantMembers.Get", api.TenantMembers_GetReq{ Tenant: tenant.Tenant.ID, UserID: inviteeID, }, &body) assert.Eq(t, getRes.StatusCode, http.StatusNotFound) } func TestTenantInvitationsApi_List_OwnerView(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) for _, email := range []string{"a@test.com", "b@test.com", "c@test.com"} { s.call(t, "/TenantInvitations.Create", api.TenantInvitations_CreateReq{ Tenant: tenant.Tenant.ID, Email: email, }, nil) } var list api.TenantInvitations_ListRes s.call(t, "/TenantInvitations.List", api.TenantInvitations_ListReq{ Tenant: tenant.Tenant.ID, }, &list) assert.Eq(t, len(list.Page.Items), 3) } func TestTenantInvitationsApi_ListByEmail_SelfAllowed_OthersForbidden(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.seedUser(t, "invitee@test.com", "pw") s.seedUser(t, "other@test.com", "pw") s.call(t, "/TenantInvitations.Create", api.TenantInvitations_CreateReq{ Tenant: tenant.Tenant.ID, Email: "invitee@test.com", }, nil) // Self path. s.login(t, "invitee@test.com", "pw") var list api.TenantInvitations_ListByEmailRes s.call(t, "/TenantInvitations.ListByEmail", api.TenantInvitations_ListByEmailReq{ Email: "invitee@test.com", }, &list) assert.Eq(t, len(list.Page.Items), 1) // Other user looking at someone else's invites is forbidden. s.login(t, "other@test.com", "pw") var body api.ErrorResponse res := s.call(t, "/TenantInvitations.ListByEmail", api.TenantInvitations_ListByEmailReq{ Email: "invitee@test.com", }, &body) assert.Eq(t, res.StatusCode, http.StatusForbidden) } func TestTenantInvitationsApi_Create_DuplicateRejected(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) var body api.ErrorResponse res := s.call(t, "/TenantInvitations.Create", api.TenantInvitations_CreateReq{ Tenant: tenant.Tenant.ID, Email: "dup@test.com", }, &body) assert.Eq(t, res.StatusCode, http.StatusInternalServerError) if !strings.Contains(body.Message, iam.ErrAlreadyExists.Error()) { t.Fatalf("expected error message to contain %q, got %q", iam.ErrAlreadyExists.Error(), body.Message) } } func TestTenantInvitationsApi_NonAnon_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) s.logout(t) var body api.ErrorResponse res := s.call(t, "/TenantInvitations.Create", api.TenantInvitations_CreateReq{ Tenant: tenant.Tenant.ID, Email: "anon@test.com", }, &body) assert.Eq(t, res.StatusCode, http.StatusForbidden) }