package api_test import ( "bytes" "context" "database/sql" "encoding/json" "log/slog" "net/http" "net/http/cookiejar" "net/http/httptest" "testing" "time" "atlas9.dev/c/core" "atlas9.dev/c/core/assert" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/demo/api" "atlas9.dev/c/demo/boot" "atlas9.dev/c/demo/boot/bootdb" "atlas9.dev/c/demo/bots" "atlas9.dev/c/demo/lib" "atlas9.dev/c/demo/mail" ) type testServer struct { URL string DB *sql.DB server *boot.Server client *http.Client } var defaultTenant = core.NewID("t") // defaultTestCaps are granted to the default test principal so existing // happy-path tests have enough permissions without per-test setup. Extend // as new APIs are exercised. var defaultTestCaps = []iam.Cap{ iam.CapTenantsCreate, iam.CapTenantsUpdate, iam.CapTenantsRead, iam.CapTenantsDelete, iam.CapTenantMembersCreate, iam.CapTenantMembersRemove, iam.CapTenantMembersRead, iam.CapTenantInvitationsCreate, iam.CapTenantInvitationsRead, iam.CapTenantInvitationsDelete, bots.Cap_Bots_Write, bots.Cap_Bots_Read, } type loggingMailer struct{} func (l *loggingMailer) Send(ctx context.Context, email string, subject string, text string, htmlBody string) error { slog.Info("send mail", "email", email, "subject", subject, "text", text, "html", htmlBody) return nil } var _ mail.Mailer = (*loggingMailer)(nil) func startServer(t *testing.T) *testServer { db, err := bootdb.Database(":memory:") assert.Ok(t, err) t.Cleanup(func() { db.Close() }) config := boot.Config{ Session: boot.SessionConfig{ Name: "atlas9_test_session", Lifetime: time.Hour, Secure: false, }, } mailer := &loggingMailer{} server := boot.NewServer("http://localhostTODO:11111", db, mailer, &config) ts := httptest.NewServer(server) t.Cleanup(ts.Close) jar, err := cookiejar.New(nil) assert.Ok(t, err) s := &testServer{ URL: ts.URL, DB: db, server: server, client: &http.Client{Jar: jar}, } // Insert the defaultTenant row so tenant_members FKs are satisfied // when seedUserInTenant writes a membership for it. _, err = db.ExecContext(t.Context(), `INSERT INTO tenants (id, name) VALUES ($1, $2)`, defaultTenant, "default") assert.Ok(t, err) // Seed and log in a default principal so existing happy-path tests // work without per-test auth setup. email, password := "default@test", "pass" s.seedUser(t, email, password, defaultTestCaps...) s.login(t, email, password) return s } // seedUser creates a user with a password, and — if caps is non-empty — // a system-scoped role granted to that user. See seedUserInTenant for // tenant-scoped grants. func (s *testServer) seedUser(t *testing.T, email, password string, caps ...iam.Cap) core.ID { t.Helper() return s.seedUserInTenant(t, email, password, defaultTenant, caps...) } // seedUserInTenant creates a user with a password and grants caps scoped to // the given tenant. When tenant is defaultTenant the grants are loaded as // system-scoped (apply to any tenant); otherwise they apply only to that // tenant. Writes under an admin-granted context — scaffolding, not under // test. Returns the user's ID. func (s *testServer) seedUserInTenant(t *testing.T, email, password string, tenant core.ID, caps ...iam.Cap) core.ID { t.Helper() ctx := lib.PutAccess(t.Context(), lib.AdminAccess()) userID := core.NewID("t") err := dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) error { users := s.server.Users(tx) passwords := s.server.Passwords(tx) members := s.server.TenantMembers(tx) roles := s.server.Roles(tx) grants := s.server.Grants(tx) if err := users.Save(ctx, &iam.User{ID: userID, Email: email, Verified: true}); err != nil { return err } if err := iam.SetPassword(ctx, passwords, userID, password); err != nil { return err } if err := members.Create(ctx, iam.TenantMember{Tenant: tenant, UserID: userID}); err != nil { return err } if len(caps) == 0 { return nil } // TODO probably shouldn't need a custom role in the test suite, // just need to get around to defining core app roles. slug := "test-role-" + userID.String() if err := roles.Save(ctx, &iam.Role{ Tenant: tenant, Slug: slug, Name: "Test Role", Caps: caps, }); err != nil { return err } return grants.Add(ctx, iam.Grant{ Tenant: tenant, Type: "user", Principal: userID.String(), Role: slug, System: tenant == defaultTenant, }) }) assert.Ok(t, err) return userID } // addMember adds an existing user to a tenant as a non-owner member under // admin access. Test scaffolding — not exercising the Create code path under // test, just seeding state for List/Remove/Get tests. func (s *testServer) addMember(t *testing.T, tenant, userID core.ID) { t.Helper() ctx := lib.PutAccess(t.Context(), lib.AdminAccess()) err := dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) error { return s.server.TenantMembers(tx).Create(ctx, iam.TenantMember{ Tenant: tenant, UserID: userID, }) }) assert.Ok(t, err) } // login POSTs /Identity.Login. The session cookie flows back via the jar. func (s *testServer) login(t *testing.T, email, password string) { var res api.Identity_LoginRes httpRes := s.call(t, "/Identity.Login", api.Identity_LoginReq{Email: email, Password: password}, &res) if httpRes.StatusCode != http.StatusOK { t.Fatalf("login: status %d", httpRes.StatusCode) } } // logout POSTs /Identity.Logout, clearing the session cookie. func (s *testServer) logout(t *testing.T) { t.Helper() s.call(t, "/Identity.Logout", struct{}{}, nil) } // call POSTs req as JSON to path, decodes the body into res, and returns the // response for callers that need to assert on status or headers. Pass nil for // res when the endpoint returns no body. If req is []byte, it is sent verbatim // instead of JSON-encoded — useful for negative tests that send malformed JSON. func (s *testServer) call(t *testing.T, path string, req, res any) *http.Response { var body []byte if b, ok := req.([]byte); ok { body = b } else { var err error body, err = json.Marshal(req) assert.Ok(t, err) } httpReq, err := http.NewRequestWithContext(t.Context(), "POST", s.URL+path, bytes.NewReader(body)) assert.Ok(t, err) httpReq.Header.Set("Content-Type", "application/json") httpRes, err := s.client.Do(httpReq) assert.Ok(t, err) t.Cleanup(func() { httpRes.Body.Close() }) if res != nil { assert.Ok(t, json.NewDecoder(httpRes.Body).Decode(res)) } return httpRes }