| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | "time" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/svcrole" |
| 9 | ) |
| 10 | |
| 11 | // mustAccount mints a fresh account for a test, using the same constructor |
| 12 | // the console itself calls (Store.CreateAccount) and the package's own |
| 13 | // testAccountID for a collision-free id. |
| 14 | func mustAccount(t *testing.T, s *Store, name string) *Account { |
| 15 | t.Helper() |
| 16 | id := testAccountID() |
| 17 | if err := s.CreateAccount(id, name, name, "paul@keymail.dev", time.Now()); err != nil { |
| 18 | t.Fatalf("CreateAccount(%q): %v", name, err) |
| 19 | } |
| 20 | return &Account{ID: id, Sqid: name, Name: name} |
| 21 | } |
| 22 | |
| 23 | // TestCLIPrincipalLegacyAppTokenIsInstanceRole is the migration guarantee: |
| 24 | // a row written before this change must read back with IDENTICAL authority. |
| 25 | func TestCLIPrincipalLegacyAppTokenIsInstanceRole(t *testing.T) { |
| 26 | s := openTestStore(t) |
| 27 | now := time.Now() |
| 28 | acct := mustAccount(t, s, "legacy") |
| 29 | raw, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now) |
| 30 | if err != nil { |
| 31 | t.Fatalf("NewAppToken: %v", err) |
| 32 | } |
| 33 | p, ok := s.CLIPrincipal(raw, now) |
| 34 | if !ok { |
| 35 | t.Fatal("CLIPrincipal(app token) = !ok; want ok") |
| 36 | } |
| 37 | if p.Kind != PrincipalService { |
| 38 | t.Errorf("Kind = %v; want PrincipalService", p.Kind) |
| 39 | } |
| 40 | if p.Role != svcrole.Instance { |
| 41 | t.Errorf("Role = %q; want %q", p.Role, svcrole.Instance) |
| 42 | } |
| 43 | if p.AccountID != acct.ID || p.App != "titogo" { |
| 44 | t.Errorf("scope = %d/%q; want %d/%q", p.AccountID, p.App, acct.ID, "titogo") |
| 45 | } |
| 46 | // The minter is a recorded fact, never the owner. |
| 47 | if p.Address != "paul@example.test" { |
| 48 | t.Errorf("Address = %q; want the minter", p.Address) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func TestCLIPrincipalUserToken(t *testing.T) { |
| 53 | s := openTestStore(t) |
| 54 | now := time.Now() |
| 55 | raw := mintUserToken(t, s, "paul@example.test", now) |
| 56 | p, ok := s.CLIPrincipal(raw, now) |
| 57 | if !ok { |
| 58 | t.Fatal("CLIPrincipal(user token) = !ok; want ok") |
| 59 | } |
| 60 | if p.Kind != PrincipalUser { |
| 61 | t.Errorf("Kind = %v; want PrincipalUser", p.Kind) |
| 62 | } |
| 63 | if p.Address != "paul@example.test" { |
| 64 | t.Errorf("Address = %q", p.Address) |
| 65 | } |
| 66 | // A person carries no role and no scope. Anything else here would let a |
| 67 | // role check read a person as a service with authority. |
| 68 | if p.Role != "" || p.AccountID != 0 || p.App != "" || p.Name != "" { |
| 69 | t.Errorf("person carries scope/role: %+v", p) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | func TestCLIPrincipalServiceToken(t *testing.T) { |
| 74 | s := openTestStore(t) |
| 75 | now := time.Now() |
| 76 | acct := mustAccount(t, s, "amadan") |
| 77 | raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) |
| 78 | if err != nil { |
| 79 | t.Fatalf("NewServicePrincipal: %v", err) |
| 80 | } |
| 81 | p, ok := s.CLIPrincipal(raw, now) |
| 82 | if !ok { |
| 83 | t.Fatal("CLIPrincipal(service token) = !ok; want ok") |
| 84 | } |
| 85 | if p.Kind != PrincipalService || p.Role != svcrole.Publish || p.Name != "hub" { |
| 86 | t.Errorf("got %+v", p) |
| 87 | } |
| 88 | if p.AccountID != acct.ID { |
| 89 | t.Errorf("AccountID = %d; want %d", p.AccountID, acct.ID) |
| 90 | } |
| 91 | if p.App != "" { |
| 92 | t.Errorf("App = %q; want account-scoped (empty)", p.App) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // TestServiceTokenIsNotAPerson is the containment property read from the |
| 97 | // other side: the person door must not answer for a service credential, and |
| 98 | // its refusal must be the one a fabricated token gets. |
| 99 | func TestServiceTokenIsNotAPerson(t *testing.T) { |
| 100 | s := openTestStore(t) |
| 101 | now := time.Now() |
| 102 | acct := mustAccount(t, s, "amadan") |
| 103 | raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) |
| 104 | if err != nil { |
| 105 | t.Fatalf("NewServicePrincipal: %v", err) |
| 106 | } |
| 107 | if addr, ok := s.CLITokenAddress(raw, now); ok { |
| 108 | t.Errorf("CLITokenAddress(service token) = %q,true; want \"\",false", addr) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestRevokedServicePrincipalStopsResolving(t *testing.T) { |
| 113 | s := openTestStore(t) |
| 114 | now := time.Now() |
| 115 | acct := mustAccount(t, s, "amadan") |
| 116 | raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) |
| 117 | if err != nil { |
| 118 | t.Fatalf("NewServicePrincipal: %v", err) |
| 119 | } |
| 120 | if err := s.RevokeServicePrincipal(acct.ID, "hub", now); err != nil { |
| 121 | t.Fatalf("RevokeServicePrincipal: %v", err) |
| 122 | } |
| 123 | if _, ok := s.CLIPrincipal(raw, now); ok { |
| 124 | t.Error("CLIPrincipal(revoked) = ok; want !ok") |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // TestRotateKeepsTheNameAndKillsTheOldSecret is why `name` exists as a column |
| 129 | // separate from the token hash: rate-limit keys and audit rows stay |
| 130 | // continuous across a secret change, and the old secret stops working in the |
| 131 | // same call. |
| 132 | func TestRotateKeepsTheNameAndKillsTheOldSecret(t *testing.T) { |
| 133 | s := openTestStore(t) |
| 134 | now := time.Now() |
| 135 | acct := mustAccount(t, s, "amadan") |
| 136 | old, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) |
| 137 | if err != nil { |
| 138 | t.Fatalf("NewServicePrincipal: %v", err) |
| 139 | } |
| 140 | fresh, err := s.RotateServicePrincipal(acct.ID, "hub", now) |
| 141 | if err != nil { |
| 142 | t.Fatalf("RotateServicePrincipal: %v", err) |
| 143 | } |
| 144 | if fresh == old { |
| 145 | t.Fatal("rotate returned the same secret") |
| 146 | } |
| 147 | if _, ok := s.CLIPrincipal(old, now); ok { |
| 148 | t.Error("old secret still resolves after rotate") |
| 149 | } |
| 150 | p, ok := s.CLIPrincipal(fresh, now) |
| 151 | if !ok { |
| 152 | t.Fatal("fresh secret does not resolve") |
| 153 | } |
| 154 | if p.Name != "hub" || p.Role != svcrole.Publish { |
| 155 | t.Errorf("rotate changed identity: %+v", p) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // TestNameIsUniquePerAccountAmongLiveRows — the name is the handle every |
| 160 | // other surface addresses, so two live rows sharing one would make rotate and |
| 161 | // revoke ambiguous. |
| 162 | func TestNameIsUniquePerAccountAmongLiveRows(t *testing.T) { |
| 163 | s := openTestStore(t) |
| 164 | now := time.Now() |
| 165 | acct := mustAccount(t, s, "amadan") |
| 166 | if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { |
| 167 | t.Fatalf("first mint: %v", err) |
| 168 | } |
| 169 | if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Operate, "paul@example.test", now); err == nil { |
| 170 | t.Fatal("second mint with the same name succeeded; want ErrServiceNameTaken") |
| 171 | } |
| 172 | // A different account may hold the same name: the namespace is per |
| 173 | // account, like an app name, not global like a fleet name. |
| 174 | other := mustAccount(t, s, "other") |
| 175 | if _, err := s.NewServicePrincipal(other.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { |
| 176 | t.Errorf("same name in another account: %v", err) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // TestUnparseableRoleRefusesRatherThanFallingThrough: a kind='service' row |
| 181 | // whose role name this binary does not know must refuse outright, never |
| 182 | // resolve as a role-less service a gate might treat as anything. |
| 183 | func TestUnparseableRoleRefusesRatherThanFallingThrough(t *testing.T) { |
| 184 | s := openTestStore(t) |
| 185 | now := time.Now() |
| 186 | acct := mustAccount(t, s, "amadan") |
| 187 | raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) |
| 188 | if err != nil { |
| 189 | t.Fatalf("NewServicePrincipal: %v", err) |
| 190 | } |
| 191 | if _, err := s.db.Exec(`UPDATE cli_tokens SET role = 'made-up-role' WHERE token_hash = ?`, hashToken(raw)); err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | if _, ok := s.CLIPrincipal(raw, now); ok { |
| 195 | t.Error("CLIPrincipal(unparseable role) = ok; want !ok") |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | // TestServicePrincipalsListsBothKinds: the account's listing must show |
| 200 | // legacy kind='app' rows too, normalized to the instance role, or absorbing |
| 201 | // them into the new model would make them invisible to their own owner. |
| 202 | func TestServicePrincipalsListsBothKinds(t *testing.T) { |
| 203 | s := openTestStore(t) |
| 204 | now := time.Now() |
| 205 | acct := mustAccount(t, s, "amadan") |
| 206 | if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { |
| 207 | t.Fatalf("NewServicePrincipal: %v", err) |
| 208 | } |
| 209 | if _, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now); err != nil { |
| 210 | t.Fatalf("NewAppToken: %v", err) |
| 211 | } |
| 212 | toks, err := s.ServicePrincipals(acct.ID) |
| 213 | if err != nil { |
| 214 | t.Fatal(err) |
| 215 | } |
| 216 | if len(toks) != 2 { |
| 217 | t.Fatalf("ServicePrincipals = %d rows, want 2", len(toks)) |
| 218 | } |
| 219 | var svc, app *CLIToken |
| 220 | for i := range toks { |
| 221 | switch toks[i].Kind { |
| 222 | case cliKindApp: |
| 223 | app = &toks[i] |
| 224 | case cliKindService: |
| 225 | svc = &toks[i] |
| 226 | } |
| 227 | } |
| 228 | if svc == nil || app == nil { |
| 229 | t.Fatalf("ServicePrincipals = %+v, want one row of each kind", toks) |
| 230 | } |
| 231 | if svc.Name != "hub" || svc.Role != string(svcrole.Publish) { |
| 232 | t.Errorf("service row = %+v", svc) |
| 233 | } |
| 234 | if app.Role != string(svcrole.Instance) { |
| 235 | t.Errorf("legacy app row Role = %q; want normalized to %q", app.Role, svcrole.Instance) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | // TestRotateAndRevokeIgnoreLegacyNamelessRows: a legacy kind='app' row's name |
| 240 | // is empty, and RotateServicePrincipal/RevokeServicePrincipal must never treat |
| 241 | // that empty string as a name to match — that would let one call rotate or |
| 242 | // revoke an unrelated, unnamed credential. |
| 243 | func TestRotateAndRevokeIgnoreLegacyNamelessRows(t *testing.T) { |
| 244 | s := openTestStore(t) |
| 245 | now := time.Now() |
| 246 | acct := mustAccount(t, s, "amadan") |
| 247 | app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now) |
| 248 | if err != nil { |
| 249 | t.Fatalf("NewAppToken: %v", err) |
| 250 | } |
| 251 | if _, err := s.RotateServicePrincipal(acct.ID, "", now); err == nil { |
| 252 | t.Fatal("RotateServicePrincipal(\"\") succeeded against a legacy row; want a refusal") |
| 253 | } |
| 254 | if err := s.RevokeServicePrincipal(acct.ID, "", now); err == nil { |
| 255 | t.Fatal("RevokeServicePrincipal(\"\") succeeded against a legacy row; want a refusal") |
| 256 | } |
| 257 | // The legacy app token must still work: neither call touched it. |
| 258 | if _, ok := s.CLIPrincipal(app, now); !ok { |
| 259 | t.Error("legacy app token stopped resolving after a no-op rotate/revoke by empty name") |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | // TestRevokeAccountTokenScopesByAccountNotAddress: the only handle a legacy |
| 264 | // nameless row has is its listing id, and the account (not the minting |
| 265 | // address) must be able to use it. |
| 266 | func TestRevokeAccountTokenScopesByAccountNotAddress(t *testing.T) { |
| 267 | s := openTestStore(t) |
| 268 | now := time.Now() |
| 269 | acct := mustAccount(t, s, "amadan") |
| 270 | other := mustAccount(t, s, "other") |
| 271 | app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now) |
| 272 | if err != nil { |
| 273 | t.Fatalf("NewAppToken: %v", err) |
| 274 | } |
| 275 | id := hashToken(app) |
| 276 | if err := s.RevokeAccountToken(other.ID, id, now); err == nil { |
| 277 | t.Fatal("a different account revoked this one's token") |
| 278 | } |
| 279 | if _, ok := s.CLIPrincipal(app, now); !ok { |
| 280 | t.Fatal("token stopped working after a refused cross-account revoke") |
| 281 | } |
| 282 | if err := s.RevokeAccountToken(acct.ID, id, now); err != nil { |
| 283 | t.Fatalf("RevokeAccountToken: %v", err) |
| 284 | } |
| 285 | if _, ok := s.CLIPrincipal(app, now); ok { |
| 286 | t.Error("token still resolves after RevokeAccountToken") |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | // TestNewServicePrincipalRejectsEmptyName: an empty name is not one a caller chose — |
| 291 | // it is the default every legacy kind='app' row already carries — and both |
| 292 | // RotateServicePrincipal and RevokeServicePrincipal refuse to match it, so a |
| 293 | // kind='service' row minted with an empty name would be unaddressable except by |
| 294 | // RevokeAccountToken's raw id. The store refuses it at the door instead. |
| 295 | func TestNewServicePrincipalRejectsEmptyName(t *testing.T) { |
| 296 | s := openTestStore(t) |
| 297 | now := time.Now() |
| 298 | acct := mustAccount(t, s, "amadan") |
| 299 | if _, err := s.NewServicePrincipal(acct.ID, "", "", svcrole.Publish, "paul@example.test", now); err == nil { |
| 300 | t.Fatal("NewServicePrincipal(name=\"\") succeeded; want a refusal") |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | // TestServiceKindNeverExpiresAndIsService is C8's missing test: a |
| 305 | // kind='service' row must report NeverExpires() == true (expires_at is 0 by |
| 306 | // design) and IsService() == true — otherwise templates/settings.html would |
| 307 | // render every service credential as expiring 1 Jan 1970, the exact failure |
| 308 | // C8 exists to prevent. |
| 309 | func TestServiceKindNeverExpiresAndIsService(t *testing.T) { |
| 310 | s := openTestStore(t) |
| 311 | now := time.Now() |
| 312 | acct := mustAccount(t, s, "amadan") |
| 313 | if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { |
| 314 | t.Fatalf("NewServicePrincipal: %v", err) |
| 315 | } |
| 316 | toks, err := s.ServicePrincipals(acct.ID) |
| 317 | if err != nil { |
| 318 | t.Fatal(err) |
| 319 | } |
| 320 | if len(toks) != 1 { |
| 321 | t.Fatalf("ServicePrincipals = %d rows, want 1", len(toks)) |
| 322 | } |
| 323 | tok := toks[0] |
| 324 | if !tok.IsService() { |
| 325 | t.Error("IsService() = false for a kind='service' row") |
| 326 | } |
| 327 | if tok.IsApp() { |
| 328 | t.Error("IsApp() = true for a kind='service' row") |
| 329 | } |
| 330 | if !tok.NeverExpires() { |
| 331 | t.Error("NeverExpires() = false for a service principal; would render as expiring 1 Jan 1970") |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | // TestCLITokenAddressRefusesRevokedUserRow exercises a state the Go API |
| 336 | // cannot otherwise produce (RevokeCLIToken deletes rather than soft-revokes |
| 337 | // a user row): same raw-SQL technique as |
| 338 | // TestUnparseableRoleRefusesRatherThanFallingThrough, proving the |
| 339 | // revoked_at != 0 branch in CLITokenAddress is live code, not dead code. |
| 340 | func TestCLITokenAddressRefusesRevokedUserRow(t *testing.T) { |
| 341 | s := openTestStore(t) |
| 342 | now := time.Now() |
| 343 | raw := mintUserToken(t, s, "paul@example.test", now) |
| 344 | if _, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ? WHERE token_hash = ?`, now.Unix(), hashToken(raw)); err != nil { |
| 345 | t.Fatal(err) |
| 346 | } |
| 347 | if addr, ok := s.CLITokenAddress(raw, now); ok { |
| 348 | t.Errorf("CLITokenAddress(revoked user row) = %q,true; want \"\",false", addr) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | // TestRevokedNameIsFreeForAFreshMint: RevokeServicePrincipal tombstones the |
| 353 | // row rather than deleting it (Risk A), and the unique index |
| 354 | // (cli_tokens_service_name) is scoped to live rows — a revoked `hub` must |
| 355 | // not permanently squat the name. |
| 356 | func TestRevokedNameIsFreeForAFreshMint(t *testing.T) { |
| 357 | s := openTestStore(t) |
| 358 | now := time.Now() |
| 359 | acct := mustAccount(t, s, "amadan") |
| 360 | if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { |
| 361 | t.Fatalf("first mint: %v", err) |
| 362 | } |
| 363 | if err := s.RevokeServicePrincipal(acct.ID, "hub", now); err != nil { |
| 364 | t.Fatalf("RevokeServicePrincipal: %v", err) |
| 365 | } |
| 366 | if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Operate, "paul@example.test", now); err != nil { |
| 367 | t.Fatalf("re-mint after revoke: %v", err) |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | // TestRenameAppMovesServicePrincipalToo is F1's authority bug, made |
| 372 | // concrete: an app-scoped service principal that does not follow a rename |
| 373 | // becomes an orphaned credential a LATER app of the same (freed) name |
| 374 | // inherits — authority nobody minted it for. |
| 375 | func TestRenameAppMovesServicePrincipalToo(t *testing.T) { |
| 376 | s := openTestStore(t) |
| 377 | now := time.Now() |
| 378 | acct := mustAccount(t, s, "amadan") |
| 379 | if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@example.test", now); err != nil { |
| 380 | t.Fatalf("CreateAppIn: %v", err) |
| 381 | } |
| 382 | raw, err := s.NewServicePrincipal(acct.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now) |
| 383 | if err != nil { |
| 384 | t.Fatalf("NewServicePrincipal: %v", err) |
| 385 | } |
| 386 | if err := s.RenameAppIn(acct.ID, "titogo", "tito"); err != nil { |
| 387 | t.Fatalf("RenameAppIn: %v", err) |
| 388 | } |
| 389 | // The freed name is claimable again — by a brand new app nobody minted a |
| 390 | // credential for. |
| 391 | if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@example.test", now); err != nil { |
| 392 | t.Fatalf("CreateAppIn (fresh titogo): %v", err) |
| 393 | } |
| 394 | p, ok := s.CLIPrincipal(raw, now) |
| 395 | if !ok { |
| 396 | t.Fatal("CLIPrincipal(hub) = !ok after rename; want ok") |
| 397 | } |
| 398 | if p.App != "tito" { |
| 399 | t.Errorf("App = %q after rename; want the RENAMED app (tito) — not the freed name a new, different app now holds", p.App) |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | // TestMoveAppMovesServicePrincipalToo is the MoveApp twin of the rename |
| 404 | // guard fix: an app-scoped service principal must follow its app across |
| 405 | // accounts, the same way a legacy app-kind token already does. |
| 406 | func TestMoveAppMovesServicePrincipalToo(t *testing.T) { |
| 407 | s := openTestStore(t) |
| 408 | now := time.Now() |
| 409 | from := mustAccount(t, s, "amadan") |
| 410 | to := mustAccount(t, s, "other") |
| 411 | if _, err := s.CreateAppIn(from.ID, "titogo", "paul@example.test", now); err != nil { |
| 412 | t.Fatalf("CreateAppIn: %v", err) |
| 413 | } |
| 414 | raw, err := s.NewServicePrincipal(from.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now) |
| 415 | if err != nil { |
| 416 | t.Fatalf("NewServicePrincipal: %v", err) |
| 417 | } |
| 418 | if err := s.MoveApp("titogo", from.ID, to.ID); err != nil { |
| 419 | t.Fatalf("MoveApp: %v", err) |
| 420 | } |
| 421 | p, ok := s.CLIPrincipal(raw, now) |
| 422 | if !ok { |
| 423 | t.Fatal("CLIPrincipal(hub) = !ok after move; want ok") |
| 424 | } |
| 425 | if p.AccountID != to.ID { |
| 426 | t.Errorf("AccountID = %d after move; want %d (the destination account)", p.AccountID, to.ID) |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | // TestRenameAppRefusesOntoNameWithStaleServiceCredential pins the widened |
| 431 | // half of F1 that TestRenameAppMovesServicePrincipalToo does not reach: the |
| 432 | // stale-token COUNT guard itself. Reverting that guard's `kind IN (?, ?)` |
| 433 | // back to `kind = ?` (cliKindApp alone) would leave this test's refusal |
| 434 | // silently absent — the rename would succeed onto a name a live service |
| 435 | // credential still holds. |
| 436 | func TestRenameAppRefusesOntoNameWithStaleServiceCredential(t *testing.T) { |
| 437 | s := openTestStore(t) |
| 438 | now := time.Now() |
| 439 | acct := mustAccount(t, s, "amadan") |
| 440 | if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@example.test", now); err != nil { |
| 441 | t.Fatalf("CreateAppIn: %v", err) |
| 442 | } |
| 443 | if _, err := s.NewServicePrincipal(acct.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now); err != nil { |
| 444 | t.Fatalf("NewServicePrincipal: %v", err) |
| 445 | } |
| 446 | // The credential above follows the rename (proven separately by |
| 447 | // TestRenameAppMovesServicePrincipalToo), so "titogo" is now clean. A |
| 448 | // SECOND credential, minted directly against the freed name with no live |
| 449 | // app behind it, stands in for what a purged predecessor's app-kind |
| 450 | // token has always left behind — the exact shape the guard's own doc |
| 451 | // comment describes, and the only way to construct it without a purge |
| 452 | // path this test does not otherwise need. |
| 453 | if err := s.RenameAppIn(acct.ID, "titogo", "tito"); err != nil { |
| 454 | t.Fatalf("RenameAppIn (move away): %v", err) |
| 455 | } |
| 456 | if _, err := s.NewServicePrincipal(acct.ID, "leftover", "titogo", svcrole.Operate, "paul@example.test", now); err != nil { |
| 457 | t.Fatalf("NewServicePrincipal (leftover): %v", err) |
| 458 | } |
| 459 | if _, err := s.CreateAppIn(acct.ID, "second", "paul@example.test", now); err != nil { |
| 460 | t.Fatalf("CreateAppIn (second): %v", err) |
| 461 | } |
| 462 | err := s.RenameAppIn(acct.ID, "second", "titogo") |
| 463 | if err == nil { |
| 464 | t.Fatal("RenameAppIn onto a name with a stale service credential succeeded; want a refusal") |
| 465 | } |
| 466 | if !strings.Contains(err.Error(), "app credentials from a previous app of that name") { |
| 467 | t.Errorf("error = %q, want the stale-token guard's message", err.Error()) |
| 468 | } |
| 469 | } |
| 470 | |
| 471 | // TestMoveAppRefusesOntoNameWithStaleServiceCredential is MoveApp's twin of |
| 472 | // the test above: the equivalent COUNT guard, at the destination account. |
| 473 | func TestMoveAppRefusesOntoNameWithStaleServiceCredential(t *testing.T) { |
| 474 | s := openTestStore(t) |
| 475 | now := time.Now() |
| 476 | from := mustAccount(t, s, "amadan") |
| 477 | to := mustAccount(t, s, "other") |
| 478 | if _, err := s.CreateAppIn(from.ID, "titogo", "paul@example.test", now); err != nil { |
| 479 | t.Fatalf("CreateAppIn: %v", err) |
| 480 | } |
| 481 | // A leftover, purged-predecessor-shaped service credential already sits |
| 482 | // at the DESTINATION under the same app name the move targets. |
| 483 | if _, err := s.NewServicePrincipal(to.ID, "leftover", "titogo", svcrole.Operate, "paul@example.test", now); err != nil { |
| 484 | t.Fatalf("NewServicePrincipal (leftover at destination): %v", err) |
| 485 | } |
| 486 | err := s.MoveApp("titogo", from.ID, to.ID) |
| 487 | if err == nil { |
| 488 | t.Fatal("MoveApp onto a destination with a stale service credential succeeded; want a refusal") |
| 489 | } |
| 490 | if !strings.Contains(err.Error(), "app credentials for") { |
| 491 | t.Errorf("error = %q, want the stale-token guard's message", err.Error()) |
| 492 | } |
| 493 | } |
| 494 | |
| 495 | // TestMoveAppRefusesOntoNameCollisionWithAccountScopedCredential is C18: the |
| 496 | // stale-token guard above only counts rows still bound to the app being |
| 497 | // moved, so it cannot see an ACCOUNT-scoped credential already live at the |
| 498 | // destination under the same name — a collision the unique index |
| 499 | // (cli_tokens_service_name) would otherwise catch as a raw constraint |
| 500 | // error. This is MoveApp's second, narrower guard. |
| 501 | func TestMoveAppRefusesOntoNameCollisionWithAccountScopedCredential(t *testing.T) { |
| 502 | s := openTestStore(t) |
| 503 | now := time.Now() |
| 504 | from := mustAccount(t, s, "amadan") |
| 505 | to := mustAccount(t, s, "other") |
| 506 | if _, err := s.CreateAppIn(from.ID, "titogo", "paul@example.test", now); err != nil { |
| 507 | t.Fatalf("CreateAppIn: %v", err) |
| 508 | } |
| 509 | traveler, err := s.NewServicePrincipal(from.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now) |
| 510 | if err != nil { |
| 511 | t.Fatalf("NewServicePrincipal (source, app-scoped): %v", err) |
| 512 | } |
| 513 | resident, err := s.NewServicePrincipal(to.ID, "hub", "", svcrole.Operate, "paul@example.test", now) |
| 514 | if err != nil { |
| 515 | t.Fatalf("NewServicePrincipal (destination, account-scoped): %v", err) |
| 516 | } |
| 517 | if err := s.MoveApp("titogo", from.ID, to.ID); err == nil { |
| 518 | t.Fatal("MoveApp onto a name-colliding destination succeeded; want a refusal") |
| 519 | } else if !strings.Contains(err.Error(), "hub") || !strings.Contains(err.Error(), "already holds a live service credential") { |
| 520 | t.Errorf("error = %q, want it to name the colliding credential", err.Error()) |
| 521 | } |
| 522 | // The refusal must be transactional: both credentials keep their |
| 523 | // original scope, neither dropped nor merged. |
| 524 | tp, ok := s.CLIPrincipal(traveler, now) |
| 525 | if !ok || tp.AccountID != from.ID || tp.App != "titogo" { |
| 526 | t.Errorf("traveling credential changed scope: %+v ok=%v", tp, ok) |
| 527 | } |
| 528 | rp, ok := s.CLIPrincipal(resident, now) |
| 529 | if !ok || rp.AccountID != to.ID || rp.App != "" { |
| 530 | t.Errorf("resident credential changed scope: %+v ok=%v", rp, ok) |
| 531 | } |
| 532 | } |
| 533 | |
| 534 | // TestRotateAdvancesRotatedAtLeavesCreatedAt is C19: rotated_at is when the |
| 535 | // CURRENT secret began (stamped at mint and at every rotate); created_at is |
| 536 | // when the credential was granted and never moves again. This is also the |
| 537 | // third bullet of C21 read from the other side — reverting the created_at |
| 538 | // preservation this test also covers would fail it. |
| 539 | func TestRotateAdvancesRotatedAtLeavesCreatedAt(t *testing.T) { |
| 540 | s := openTestStore(t) |
| 541 | minted := time.Now() |
| 542 | acct := mustAccount(t, s, "amadan") |
| 543 | if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", minted); err != nil { |
| 544 | t.Fatalf("NewServicePrincipal: %v", err) |
| 545 | } |
| 546 | rotated := minted.Add(48 * time.Hour) |
| 547 | if _, err := s.RotateServicePrincipal(acct.ID, "hub", rotated); err != nil { |
| 548 | t.Fatalf("RotateServicePrincipal: %v", err) |
| 549 | } |
| 550 | toks, err := s.ServicePrincipals(acct.ID) |
| 551 | if err != nil { |
| 552 | t.Fatal(err) |
| 553 | } |
| 554 | if len(toks) != 1 { |
| 555 | t.Fatalf("ServicePrincipals = %d rows, want 1", len(toks)) |
| 556 | } |
| 557 | tok := toks[0] |
| 558 | if tok.CreatedAt.Unix() != minted.Unix() { |
| 559 | t.Errorf("CreatedAt = %v after rotate, want the original mint time %v", tok.CreatedAt, minted) |
| 560 | } |
| 561 | if tok.RotatedAt.Unix() != rotated.Unix() { |
| 562 | t.Errorf("RotatedAt = %v after rotate, want the rotate time %v", tok.RotatedAt, rotated) |
| 563 | } |
| 564 | } |
| 565 | |
| 566 | // TestNewServicePrincipalStampsRotatedAtAtMint: the current secret begins |
| 567 | // exactly when the credential does, so a freshly minted row's rotated_at |
| 568 | // must equal its created_at, not read as 0 (which would misrender as |
| 569 | // "never rotated" rather than "rotated when granted"). |
| 570 | func TestNewServicePrincipalStampsRotatedAtAtMint(t *testing.T) { |
| 571 | s := openTestStore(t) |
| 572 | now := time.Now() |
| 573 | acct := mustAccount(t, s, "amadan") |
| 574 | if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { |
| 575 | t.Fatalf("NewServicePrincipal: %v", err) |
| 576 | } |
| 577 | toks, err := s.ServicePrincipals(acct.ID) |
| 578 | if err != nil { |
| 579 | t.Fatal(err) |
| 580 | } |
| 581 | if len(toks) != 1 { |
| 582 | t.Fatalf("ServicePrincipals = %d rows, want 1", len(toks)) |
| 583 | } |
| 584 | tok := toks[0] |
| 585 | if tok.RotatedAt.Unix() != tok.CreatedAt.Unix() { |
| 586 | t.Errorf("RotatedAt = %v, CreatedAt = %v; want them equal at mint", tok.RotatedAt, tok.CreatedAt) |
| 587 | } |
| 588 | if tok.RotatedAt.Unix() != now.Unix() { |
| 589 | t.Errorf("RotatedAt = %v; want the mint time %v", tok.RotatedAt, now) |
| 590 | } |
| 591 | } |
| 592 | |
| 593 | // TestRevokeAccountTokenRefusesZeroAccount is C20: the same fact that makes |
| 594 | // RevokeAccountToken's kind-less query safe for every real caller — a |
| 595 | // person's row always carries account_id = 0 — makes accountID == 0 |
| 596 | // catastrophic for a caller that reaches this door by mistake. Proven from |
| 597 | // the dangerous side: pass 0 and a real person's own listing id, and assert |
| 598 | // their token survives. |
| 599 | func TestRevokeAccountTokenRefusesZeroAccount(t *testing.T) { |
| 600 | s := openTestStore(t) |
| 601 | now := time.Now() |
| 602 | raw := mintUserToken(t, s, "paul@example.test", now) |
| 603 | id := hashToken(raw) |
| 604 | if err := s.RevokeAccountToken(0, id, now); err == nil { |
| 605 | t.Fatal("RevokeAccountToken(accountID=0) succeeded; want a refusal") |
| 606 | } |
| 607 | if _, ok := s.CLITokenAddress(raw, now); !ok { |
| 608 | t.Error("a person's token was revoked via RevokeAccountToken(0, ...)") |
| 609 | } |
| 610 | } |
| 611 | |
| 612 | // TestRevokeAccountTokenSoftRevokesNotDeletes pins the Risk A resolution |
| 613 | // from the reviewer's side: a DELETE (the pre-fix behaviour) would leave |
| 614 | // zero rows and fail this test. |
| 615 | func TestRevokeAccountTokenSoftRevokesNotDeletes(t *testing.T) { |
| 616 | s := openTestStore(t) |
| 617 | now := time.Now() |
| 618 | acct := mustAccount(t, s, "amadan") |
| 619 | app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now) |
| 620 | if err != nil { |
| 621 | t.Fatalf("NewAppToken: %v", err) |
| 622 | } |
| 623 | id := hashToken(app) |
| 624 | if err := s.RevokeAccountToken(acct.ID, id, now); err != nil { |
| 625 | t.Fatalf("RevokeAccountToken: %v", err) |
| 626 | } |
| 627 | var count int |
| 628 | var revokedAt int64 |
| 629 | if err := s.db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(revoked_at), 0) FROM cli_tokens WHERE token_hash = ?`, id). |
| 630 | Scan(&count, &revokedAt); err != nil { |
| 631 | t.Fatal(err) |
| 632 | } |
| 633 | if count != 1 { |
| 634 | t.Fatalf("row count after RevokeAccountToken = %d, want 1 (a DELETE would leave 0)", count) |
| 635 | } |
| 636 | if revokedAt == 0 { |
| 637 | t.Error("revoked_at is still 0 after RevokeAccountToken") |
| 638 | } |
| 639 | } |
| 640 | |
| 641 | // TestRevokedServicePrincipalAbsentFromCLITokens pins the Risk B resolution: |
| 642 | // dropping CLITokens' revoked_at = 0 filter would leave this test's |
| 643 | // assertion false — the revoked row would still render on the minter's own |
| 644 | // settings page. |
| 645 | func TestRevokedServicePrincipalAbsentFromCLITokens(t *testing.T) { |
| 646 | s := openTestStore(t) |
| 647 | now := time.Now() |
| 648 | acct := mustAccount(t, s, "amadan") |
| 649 | if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { |
| 650 | t.Fatalf("NewServicePrincipal: %v", err) |
| 651 | } |
| 652 | if err := s.RevokeServicePrincipal(acct.ID, "hub", now); err != nil { |
| 653 | t.Fatalf("RevokeServicePrincipal: %v", err) |
| 654 | } |
| 655 | toks, err := s.CLITokens("paul@example.test") |
| 656 | if err != nil { |
| 657 | t.Fatal(err) |
| 658 | } |
| 659 | for _, tok := range toks { |
| 660 | if tok.Name == "hub" { |
| 661 | t.Fatalf("revoked service principal %q still present in CLITokens: %+v", tok.Name, tok) |
| 662 | } |
| 663 | } |
| 664 | } |
| 665 | |
| 666 | // TestCLIPrincipalUserRowWithZeroExpiryIsExpired: expires_at = 0 means |
| 667 | // "never" for an app or service row and "the epoch" — expired — for a |
| 668 | // person's. That asymmetry is deliberate and it is the ONE place the two |
| 669 | // resolvers could have drifted apart: CLITokenAddress has always read a |
| 670 | // person's 0 as expired, and CLIPrincipal is on its way to replacing it. |
| 671 | // NewCLIToken always stamps now+CLITokenTTL, so no such row exists today; |
| 672 | // this is the guard against the day one does, because inheriting the |
| 673 | // service kinds' "never" would make it an immortal credential. |
| 674 | func TestCLIPrincipalUserRowWithZeroExpiryIsExpired(t *testing.T) { |
| 675 | s := openTestStore(t) |
| 676 | now := time.Now() |
| 677 | insert := func(raw string, expires int64) { |
| 678 | t.Helper() |
| 679 | if _, err := s.db.Exec(`INSERT INTO cli_tokens |
| 680 | (token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app) |
| 681 | VALUES (?, 'paul@example.test', 'terminal', '', ?, ?, ?, 0, '')`, |
| 682 | hashToken(raw), now.Unix(), expires, cliKindUser); err != nil { |
| 683 | t.Fatal(err) |
| 684 | } |
| 685 | } |
| 686 | |
| 687 | // The control: an ordinary, unexpired person's row resolves. Without |
| 688 | // this the assertion below could pass on a malformed insert. |
| 689 | live, err := randomToken() |
| 690 | if err != nil { |
| 691 | t.Fatal(err) |
| 692 | } |
| 693 | insert(live, now.Add(time.Hour).Unix()) |
| 694 | p, ok := s.CLIPrincipal(live, now) |
| 695 | if !ok || p.Kind != PrincipalUser || p.Address != "paul@example.test" { |
| 696 | t.Fatalf("CLIPrincipal(live user row) = %+v,%v; want paul as a person", p, ok) |
| 697 | } |
| 698 | |
| 699 | // The guard: the same row with expires_at = 0. |
| 700 | immortal, err := randomToken() |
| 701 | if err != nil { |
| 702 | t.Fatal(err) |
| 703 | } |
| 704 | insert(immortal, 0) |
| 705 | if p, ok := s.CLIPrincipal(immortal, now); ok { |
| 706 | t.Errorf("CLIPrincipal(user row, expires_at=0) = %+v, ok; want refused — 0 is never for a service, the epoch for a person", p) |
| 707 | } |
| 708 | // And the two resolvers agree, which is the property that has to survive |
| 709 | // cliUser being folded into this one. |
| 710 | if _, ok := s.CLITokenAddress(immortal, now); ok { |
| 711 | t.Error("CLITokenAddress(user row, expires_at=0) = ok; the two resolvers disagree") |
| 712 | } |
| 713 | } |
| 714 | |