package api_test import ( "net/http" "testing" "time" "atlas9.dev/c/core" "atlas9.dev/c/core/assert" "atlas9.dev/c/core/iam" "atlas9.dev/c/demo/api" "atlas9.dev/c/demo/bots" "atlas9.dev/c/demo/todos" ) // createBotAndKey creates a bot and an API key via the API as the logged-in // default user, returning the bot ID and the bearer token. func createBotAndKey(t *testing.T, s *testServer) (core.ID, string) { t.Helper() var bot api.Bots_SaveRes httpRes := s.call(t, "/Bots_Save", api.Bots_SaveReq{ Bot: bots.Bot{Tenant: defaultTenant, Name: "builder"}, }, &bot) assert.Eq(t, httpRes.StatusCode, http.StatusOK) var key api.BotKeys_CreateRes httpRes = s.call(t, "/BotKeys_Create", api.BotKeys_CreateReq{ Tenant: defaultTenant, Bot: bot.Bot.ID, }, &key) assert.Eq(t, httpRes.StatusCode, http.StatusOK) return bot.Bot.ID, key.Token } // A bot with a valid bearer token and a granted cap can call the endpoint // that cap covers. func TestBotAuth_BearerToken(t *testing.T) { s := startServer(t) botID, token := createBotAndKey(t, s) s.seedBotGrant(t, defaultTenant, botID, todos.Cap_Todos_ReadList) httpRes := s.callBearer(t, token, "/Todos_ListLists", api.Todos_ListListsReq{ Tenant: defaultTenant, }, nil) assert.Eq(t, httpRes.StatusCode, http.StatusOK) } // A bot with a valid token but no grants is authenticated yet can do // nothing: tenant ops are 403, and unlike session principals it does not // hold the Authenticated() baseline, so Tenants_Create is also 403. func TestBotAuth_ZeroGrantBot(t *testing.T) { s := startServer(t) _, token := createBotAndKey(t, s) httpRes := s.callBearer(t, token, "/Todos_ListLists", api.Todos_ListListsReq{ Tenant: defaultTenant, }, nil) assert.Eq(t, httpRes.StatusCode, http.StatusForbidden) httpRes = s.callBearer(t, token, "/Tenants_Create", api.Tenants_CreateReq{ Tenant: iam.Tenant{Name: "Nope"}, }, nil) assert.Eq(t, httpRes.StatusCode, http.StatusForbidden) } // A presented-but-invalid bearer credential answers 401 in every form: // wrong secret, unknown key ID, malformed token, expired key. func TestBotAuth_InvalidTokens(t *testing.T) { s := startServer(t) botID, token := createBotAndKey(t, s) s.seedBotGrant(t, defaultTenant, botID, todos.Cap_Todos_ReadList) listReq := api.Todos_ListListsReq{Tenant: defaultTenant} // Wrong secret on a valid key ID. keyID, _, err := bots.ParseToken(token) assert.Ok(t, err) httpRes := s.callBearer(t, keyID+".wrong-secret", "/Todos_ListLists", listReq, nil) assert.Eq(t, httpRes.StatusCode, http.StatusUnauthorized) // Unknown key ID. unknown := core.NewID("key") httpRes = s.callBearer(t, unknown.String()+".some-secret", "/Todos_ListLists", listReq, nil) assert.Eq(t, httpRes.StatusCode, http.StatusUnauthorized) // Malformed token: no dot separator. httpRes = s.callBearer(t, "nodotinthistoken", "/Todos_ListLists", listReq, nil) assert.Eq(t, httpRes.StatusCode, http.StatusUnauthorized) // Expired key. The valid token from setup stops working. _, err = s.DB.ExecContext(t.Context(), `UPDATE bot_keys SET expires_at = $1`, time.Now().Add(-time.Minute)) assert.Ok(t, err) httpRes = s.callBearer(t, token, "/Todos_ListLists", listReq, nil) assert.Eq(t, httpRes.StatusCode, http.StatusUnauthorized) } // Only a hash of the key secret is stored. The plaintext secret appears // once, in the create response token, and never in the database. func TestBotAuth_SecretHashedAtRest(t *testing.T) { s := startServer(t) _, token := createBotAndKey(t, s) keyID, secret, err := bots.ParseToken(token) assert.Ok(t, err) var stored string err = s.DB.QueryRowContext(t.Context(), `SELECT secret_hash FROM bot_keys WHERE id = $1`, keyID).Scan(&stored) assert.Ok(t, err) if stored == secret { t.Fatal("secret stored in plaintext") } assert.Eq(t, stored, bots.HashSecret(secret)) } // BotKeys_Create honors a caller-chosen expiration, and BotKeys_Get returns // it — pinning that the store round-trips timestamps (they were previously // scanned as zero). func TestBotAuth_KeyExpirationRoundTrip(t *testing.T) { s := startServer(t) var bot api.Bots_SaveRes httpRes := s.call(t, "/Bots_Save", api.Bots_SaveReq{ Bot: bots.Bot{Tenant: defaultTenant, Name: "builder"}, }, &bot) assert.Eq(t, httpRes.StatusCode, http.StatusOK) expires := time.Now().Add(72 * time.Hour).UTC().Truncate(time.Second) var key api.BotKeys_CreateRes httpRes = s.call(t, "/BotKeys_Create", api.BotKeys_CreateReq{ Tenant: defaultTenant, Bot: bot.Bot.ID, ExpiresAt: &expires, }, &key) assert.Eq(t, httpRes.StatusCode, http.StatusOK) assert.Eq(t, key.ExpiresAt, expires) var got api.BotKeys_GetRes httpRes = s.call(t, "/BotKeys_Get", api.BotKeys_GetReq{ ID: key.ID, Tenant: defaultTenant, }, &got) assert.Eq(t, httpRes.StatusCode, http.StatusOK) assert.Eq(t, got.ExpiresAt.UTC(), expires) }