mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
905baaa010
* feat(oauth): backfill session.Extra into oauth_connections + switch read path (TASK-1522) Phase C1 for PLAN-1519. Seeds existing OAuth grant chains into the new connection tables (Phase A) and switches /console/connected-apps to read from them, retiring the session.Extra parse on the read path. Backfill (internal/store/oauth_connections_backfill.go) - Walks oauth_access_tokens + oauth_refresh_tokens to find every distinct request_id chain (including refresh-only chains). - Picks the newest token row per chain — its session.Extra drives the seeded shape, so a chain whose user re-scoped recently reflects the latest decision. - Maps session.Extra shapes to the new tables per IDEA-1517 §2: no key → all_current=1; ["*"] → all_current=1; explicit slugs → all_current=0 + one join row per slug (added_by='user'). - Resolves slugs → workspace IDs; unresolved slugs (deleted / renamed workspace) are counted + logged at WARN, not fatal. - Idempotent on every INSERT (OR IGNORE / ON CONFLICT DO NOTHING) so re-running on every startup is a cheap no-op once stable. - Returns a BackfillOAuthConnectionsResult so the startup log reports chains_seen / connections_created / workspaces_added / unresolved_slugs — operators see fresh work and notice drift. Read-path rewrite (internal/store/connected_apps.go) - ListUserOAuthConnections projects AllowedWorkspaces from GetOAuthConnectionAccess (oauth_connection_workspaces JOIN workspaces) instead of parsing session.Extra strings. - Hydrates Name + MayCreate + AllCurrent + IncludeFuture from oauth_connections so Phase D's mutation UI has them. - Defensive fallback for chains without an oauth_connections row (any leftover the backfill missed): treats as legacy "any workspace, default-on flags" so the connection still renders. Backfill at startup keeps this branch unreachable in production. - Retires parseAllowedWorkspacesFromSession; the new extractAllowedWorkspacesFromSessionExtra helper in oauth_connections_backfill.go is the only consumer of the session.Extra shape on the store side. Model (internal/models/connected_apps.go) - Adds Name / MayCreateWorkspaces / AllCurrentWorkspaces / IncludeFutureWorkspaces. AllowedWorkspaces semantics stay stable (nil = "any"; explicit slugs = chip list) so the existing DTO + frontend continue working unchanged. Phase D exposes the new fields on the wire. Startup wiring (cmd/pad/main.go) - After srv.SetOAuthServer / SetClaimSecret, run the backfill once. Non-fatal on error (partial state is consistent and the next run completes). Quiet at the Debug level on steady-state re-runs; INFO when fresh work landed. Tests - 8 BackfillOAuthConnections cases: empty DB, pre-TASK-952 (no key), wildcard, explicit list, mixed resolvable/unresolved slugs, multi-row chain newest-row-wins, refresh-only chain, idempotent re-run (verified via post-run row count). - TestExtractAllowedWorkspacesFromSessionExtra replaces the retired parseAllowedWorkspacesFromSession test — covers all three IDEA-1517 §2 input shapes + malformed/non-array defensive cases. - TestListUserOAuthConnections_DeduplicatesChain + TestHandleListConnectedApps_DTOShapeAndAuditEnrichment updated to call BackfillOAuthConnections (the production startup hook) before asserting on AllowedWorkspaces — mirrors the real-world flow now that the read path no longer parses session.Extra inline. Parent: PLAN-1519. * fix(oauth): backfill counters reflect actual new rows per Codex review (round 1) PR #583 Codex review round 1 flagged that the backfill counters over-report on steady-state restarts: - wasFreshlyInserted compared updated_at vs created_at — true for every untouched existing row, so every restart counted every pre-existing connection as "created." - slugsAdded++ ran after AddConnectionWorkspace regardless of whether the INSERT OR IGNORE / ON CONFLICT DO NOTHING hit an existing row. Net effect: startup logs "backfill complete" with non-zero counts on every restart instead of the intended quiet "no-op" path — making real fresh work indistinguishable from steady-state. Fix: probe existence BEFORE the insert on both sides. - backfillOneChain reads GetOAuthConnection first; only sets created=true and runs insertOAuthConnectionIfAbsent on a miss. - Per-slug: IsConnectionWorkspaceAllowed pre-check; skip + don't increment when the row already exists. Two cheap PK / indexed lookups per chain. Pre-Phase-C deployments have small chain counts so the added cost is well below the scan already running. Removed the now-unused wasFreshlyInserted helper. Added an assertion in TestBackfillOAuthConnections_Idempotent that both ConnectionsCreated and WorkspacesAdded report 0 on the second run — the regression guard for this exact finding. Parent: PLAN-1519. * fix(oauth): backfill skips slug re-seed on existing rows per Codex review (round 2) PR #583 round 2 caught that the round-1 fix protected the parent oauth_connections row from re-seed but left the join table mutable from stale session.Extra: When a user removes a workspace from their connection's allow-list via Phase D's mutation UI (RemoveConnectionWorkspace), the next server restart would re-run the backfill, find the parent row intact, and re-INSERT the removed slug from the original session.Extra. The user's removal would silently revert every restart. Fix: backfill is a one-shot seed. Once the parent row exists, the new tables are authoritative — legacy session.Extra is frozen reference data, not a reconciliation source. The slug loop only runs when we just inserted a fresh parent row. Added TestBackfillOAuthConnections_DoesNotResurrectRemovedWorkspace as the regression guard: seeds two slugs, removes one, runs backfill again, asserts the removed slug stays gone and the kept slug is untouched. Parent: PLAN-1519. * fix(oauth): atomic per-chain backfill transaction per Codex review (round 3) PR #583 round 3 caught that round 2's "only seed slugs on fresh parent" gate introduced a permanent-partial-state risk: if the process crashes (or AddConnectionWorkspace errors) between inserting the parent row and finishing the slug loop, the next backfill sees created=false, short-circuits the slug seeding, and leaves the connection permanently scoped to a partial allow-list. Fix: per-chain transaction. Parent insert + every slug insert land in one BEGIN/COMMIT pair; any mid-loop failure rolls everything back. The next backfill then sees the chain as un-seeded and retries from scratch — preserving both round 2's "no-resurrection of user-removed slugs" (existence probe inside the tx) and round 3's "no permanent partial seed" (atomic commit). Scope: per-chain (small tx), not whole-backfill. The original no-transaction rationale was about lock-hold duration across thousands of chains; that doesn't apply at chain granularity (one parent + a handful of join rows = sub-millisecond hold). Removed the now-unused insertOAuthConnectionIfAbsent helper; the INSERTs live inline within the transaction. Added TestBackfillOAuthConnections_AtomicOnMidLoopFailure as the regression guard: forces a mid-loop INSERT failure via a duplicate slug in session.Extra (which violates the join table's PK on the second insert), asserts the parent row rolled back, then runs a clean retry and verifies full seed completion. Parent: PLAN-1519. * fix(oauth): surface store errors from backfill + list path per Codex review (round 4) PR #583 round 4 caught two silent-fallthrough paths that could leak partial/incorrect state instead of failing loudly: 1. Backfill slug loop: GetWorkspaceBySlug errors were treated the same as "workspace not found" — both incremented slugsMissed and continued. A real I/O error mid-loop would commit a partial allow-list, and the next backfill's parent-exists short-circuit would make that partial scope permanent. Fix: distinguish (nil, nil) "not found" from (nil, err) "real failure" — return the error so the per-chain transaction rolls back and the next run retries cleanly. 2. ListUserOAuthConnections hydration: GetOAuthConnectionAccess and GetOAuthConnection errors collapsed into the "no oauth_connections row" defensive-fallback branch, returning the legacy "any workspace, default-on flags" shape. On a real store failure that silently broadens a user's scope — e.g. a connection the user explicitly removed a slug from would render as "Any workspace" until the store recovered. Fix: surface store errors from both calls; the defensive fallback path is now exclusively for HasConnection=false, not for error masking. Both findings tighten the failure mode from "silently emit broadened/partial state" to "surface the error so retries happen against accurate data." Existing tests cover the happy paths; the failure paths are exercised by I/O errors against the same store interfaces (no new test added — the change is "return err instead of swallow it" and the assertion of NOT swallowing is the diff itself). Parent: PLAN-1519.
332 lines
12 KiB
Go
332 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// Connected-apps store tests (PLAN-943 TASK-954).
|
|
//
|
|
// What's covered:
|
|
//
|
|
// - ListUserOAuthConnections deduplicates a chain across multiple
|
|
// access-token rows (refresh-token rotation produces siblings).
|
|
// - The list filters on subject — Bob can't see Alice's connections.
|
|
// - Active=FALSE chains drop off the list immediately.
|
|
// - Connected_at is the earliest timestamp in the chain.
|
|
// - GrantedScopes + AllowedWorkspaces hydrate from the newest row's
|
|
// session_data + granted_scopes (revoked rows ignored on read).
|
|
// - classifyCapabilityTier maps scope vocabulary to read_only /
|
|
// read_write / full_access / unknown.
|
|
// - parseAllowedWorkspacesFromSession round-trips both []string
|
|
// and []interface{} (post-JSON) shapes.
|
|
// - RevokeUserOAuthConnection: ownership check returns NotFound
|
|
// for stranger's chains; idempotent for already-revoked.
|
|
|
|
func newConnectedTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
s, err := New(filepath.Join(dir, "conn.db"))
|
|
if err != nil {
|
|
t.Fatalf("New store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
return s
|
|
}
|
|
|
|
// seedClient creates an oauth_clients row so chains have something
|
|
// to FK against. Returns the client ID.
|
|
func seedClient(t *testing.T, s *Store, name string) string {
|
|
t.Helper()
|
|
c, err := s.CreateOAuthClient(models.OAuthClientCreate{
|
|
Name: name,
|
|
RedirectURIs: []string{"https://example.test/cb"},
|
|
GrantTypes: []string{"authorization_code", "refresh_token"},
|
|
ResponseTypes: []string{"code"},
|
|
TokenEndpointAuthMethod: "none",
|
|
Scopes: []string{"pad:read", "pad:write"},
|
|
Public: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateOAuthClient: %v", err)
|
|
}
|
|
return c.ID
|
|
}
|
|
|
|
// seedUser creates a user that subsequent oauth tokens can use as
|
|
// their `subject`.
|
|
func seedUser(t *testing.T, s *Store, email string) string {
|
|
t.Helper()
|
|
u, err := s.CreateUser(models.UserCreate{
|
|
Email: email,
|
|
Name: "Conn Tester",
|
|
Password: "pw-conn-12345",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
return u.ID
|
|
}
|
|
|
|
// seedAccess inserts an access-token row in the chain identified by
|
|
// requestID. ts is the requested_at timestamp; sessionData + scopes
|
|
// land on the row. Returns the signature it minted.
|
|
func seedAccess(t *testing.T, s *Store, requestID, clientID, subject string, ts time.Time, sessionData, scopes string, active bool) string {
|
|
t.Helper()
|
|
sig := newID()
|
|
err := s.CreateAccessToken(models.OAuthRequest{
|
|
Signature: sig,
|
|
RequestID: requestID,
|
|
RequestedAt: ts,
|
|
ClientID: clientID,
|
|
GrantedScopes: scopes,
|
|
SessionData: sessionData,
|
|
Subject: subject,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateAccessToken: %v", err)
|
|
}
|
|
if !active {
|
|
// CreateAccessToken always inserts active=TRUE; flip if needed.
|
|
if err := s.RevokeAccessTokenFamily(requestID); err != nil {
|
|
t.Fatalf("RevokeAccessTokenFamily: %v", err)
|
|
}
|
|
}
|
|
return sig
|
|
}
|
|
|
|
func TestListUserOAuthConnections_DeduplicatesChain(t *testing.T) {
|
|
s := newConnectedTestStore(t)
|
|
uid := seedUser(t, s, "list-dedup@example.com")
|
|
clientID := seedClient(t, s, "Claude Desktop")
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
// Three access rows in the SAME chain (refresh-token rotation
|
|
// scenario). Different requested_at; same request_id.
|
|
sessionData := `{"extra":{"allowed_workspaces":["docapp"]}}`
|
|
seedAccess(t, s, "chain-1", clientID, uid, now.Add(-3*time.Hour), sessionData, "pad:read pad:write", true)
|
|
seedAccess(t, s, "chain-1", clientID, uid, now.Add(-2*time.Hour), sessionData, "pad:read pad:write", true)
|
|
seedAccess(t, s, "chain-1", clientID, uid, now.Add(-1*time.Hour), sessionData, "pad:read pad:write", true)
|
|
|
|
// Workspace + backfill so the rewritten read path (TASK-1522)
|
|
// projects AllowedWorkspaces from oauth_connection_workspaces
|
|
// instead of the retired session.Extra parse. Production wires
|
|
// the same call at startup; tests do it explicitly.
|
|
ownerForWS := seedUser(t, s, "chain-1-ws-owner@example.com")
|
|
if _, wsErr := s.CreateWorkspace(models.WorkspaceCreate{Name: "docapp", OwnerID: ownerForWS}); wsErr != nil {
|
|
t.Fatalf("CreateWorkspace docapp: %v", wsErr)
|
|
}
|
|
if _, bfErr := s.BackfillOAuthConnections(); bfErr != nil {
|
|
t.Fatalf("BackfillOAuthConnections: %v", bfErr)
|
|
}
|
|
|
|
conns, err := s.ListUserOAuthConnections(uid)
|
|
if err != nil {
|
|
t.Fatalf("ListUserOAuthConnections: %v", err)
|
|
}
|
|
if len(conns) != 1 {
|
|
t.Fatalf("got %d connections, want 1 (deduplicated chain)", len(conns))
|
|
}
|
|
c := conns[0]
|
|
if c.RequestID != "chain-1" {
|
|
t.Errorf("RequestID = %q, want chain-1", c.RequestID)
|
|
}
|
|
if c.ClientName != "Claude Desktop" {
|
|
t.Errorf("ClientName = %q, want Claude Desktop", c.ClientName)
|
|
}
|
|
if c.CapabilityTier != models.CapabilityTierReadWrite {
|
|
t.Errorf("CapabilityTier = %q, want read_write", c.CapabilityTier)
|
|
}
|
|
if !c.ConnectedAt.Equal(now.Add(-3 * time.Hour)) {
|
|
t.Errorf("ConnectedAt = %v, want earliest chain timestamp %v", c.ConnectedAt, now.Add(-3*time.Hour))
|
|
}
|
|
if len(c.AllowedWorkspaces) != 1 || c.AllowedWorkspaces[0] != "docapp" {
|
|
t.Errorf("AllowedWorkspaces = %v, want [docapp]", c.AllowedWorkspaces)
|
|
}
|
|
}
|
|
|
|
func TestListUserOAuthConnections_FiltersBySubject(t *testing.T) {
|
|
s := newConnectedTestStore(t)
|
|
alice := seedUser(t, s, "alice-conn@example.com")
|
|
bob := seedUser(t, s, "bob-conn@example.com")
|
|
clientID := seedClient(t, s, "Cursor")
|
|
|
|
now := time.Now().UTC()
|
|
seedAccess(t, s, "alice-chain", clientID, alice, now, "", "pad:read", true)
|
|
seedAccess(t, s, "bob-chain", clientID, bob, now, "", "pad:read", true)
|
|
|
|
aliceConns, err := s.ListUserOAuthConnections(alice)
|
|
if err != nil {
|
|
t.Fatalf("ListUserOAuthConnections(alice): %v", err)
|
|
}
|
|
if len(aliceConns) != 1 || aliceConns[0].RequestID != "alice-chain" {
|
|
t.Errorf("Alice should see only alice-chain, got %+v", aliceConns)
|
|
}
|
|
|
|
bobConns, err := s.ListUserOAuthConnections(bob)
|
|
if err != nil {
|
|
t.Fatalf("ListUserOAuthConnections(bob): %v", err)
|
|
}
|
|
if len(bobConns) != 1 || bobConns[0].RequestID != "bob-chain" {
|
|
t.Errorf("Bob should see only bob-chain, got %+v", bobConns)
|
|
}
|
|
}
|
|
|
|
func TestListUserOAuthConnections_ExcludesInactiveChains(t *testing.T) {
|
|
s := newConnectedTestStore(t)
|
|
uid := seedUser(t, s, "inactive@example.com")
|
|
clientID := seedClient(t, s, "Test Client")
|
|
now := time.Now().UTC()
|
|
|
|
seedAccess(t, s, "active-chain", clientID, uid, now, "", "pad:read", true)
|
|
seedAccess(t, s, "revoked-chain", clientID, uid, now, "", "pad:read", false)
|
|
|
|
conns, err := s.ListUserOAuthConnections(uid)
|
|
if err != nil {
|
|
t.Fatalf("ListUserOAuthConnections: %v", err)
|
|
}
|
|
if len(conns) != 1 {
|
|
t.Fatalf("got %d connections, want 1 (revoked chain excluded)", len(conns))
|
|
}
|
|
if conns[0].RequestID != "active-chain" {
|
|
t.Errorf("got RequestID %q, want active-chain", conns[0].RequestID)
|
|
}
|
|
}
|
|
|
|
func TestRevokeUserOAuthConnection_OwnershipCheck(t *testing.T) {
|
|
s := newConnectedTestStore(t)
|
|
alice := seedUser(t, s, "revoke-alice@example.com")
|
|
bob := seedUser(t, s, "revoke-bob@example.com")
|
|
clientID := seedClient(t, s, "Revoke Tester")
|
|
now := time.Now().UTC()
|
|
|
|
seedAccess(t, s, "alice-only", clientID, alice, now, "", "pad:read", true)
|
|
|
|
// Bob can't revoke Alice's chain — should get NotFound (not 403,
|
|
// to prevent enumeration via 403-vs-404 distinction).
|
|
if err := s.RevokeUserOAuthConnection(bob, "alice-only"); err != ErrConnectionNotFound {
|
|
t.Errorf("Bob revoking Alice's chain: got err=%v, want ErrConnectionNotFound", err)
|
|
}
|
|
|
|
// Alice's chain is still active after Bob's failed attempt.
|
|
conns, err := s.ListUserOAuthConnections(alice)
|
|
if err != nil {
|
|
t.Fatalf("ListUserOAuthConnections after failed revoke: %v", err)
|
|
}
|
|
if len(conns) != 1 {
|
|
t.Errorf("after Bob's failed revoke, Alice's chain count = %d, want 1", len(conns))
|
|
}
|
|
|
|
// Alice CAN revoke her own.
|
|
if err := s.RevokeUserOAuthConnection(alice, "alice-only"); err != nil {
|
|
t.Errorf("Alice revoking her own chain: %v", err)
|
|
}
|
|
conns, err = s.ListUserOAuthConnections(alice)
|
|
if err != nil {
|
|
t.Fatalf("ListUserOAuthConnections after revoke: %v", err)
|
|
}
|
|
if len(conns) != 0 {
|
|
t.Errorf("after Alice's revoke, chain count = %d, want 0", len(conns))
|
|
}
|
|
}
|
|
|
|
func TestRevokeUserOAuthConnection_Idempotent(t *testing.T) {
|
|
s := newConnectedTestStore(t)
|
|
uid := seedUser(t, s, "idem@example.com")
|
|
clientID := seedClient(t, s, "Idem Tester")
|
|
seedAccess(t, s, "idem-chain", clientID, uid, time.Now().UTC(), "", "pad:read", true)
|
|
|
|
if err := s.RevokeUserOAuthConnection(uid, "idem-chain"); err != nil {
|
|
t.Errorf("first revoke: %v", err)
|
|
}
|
|
if err := s.RevokeUserOAuthConnection(uid, "idem-chain"); err != nil {
|
|
t.Errorf("second revoke (idempotent): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRevokeUserOAuthConnection_UnknownChain(t *testing.T) {
|
|
s := newConnectedTestStore(t)
|
|
uid := seedUser(t, s, "unknown@example.com")
|
|
if err := s.RevokeUserOAuthConnection(uid, "no-such-chain"); err != ErrConnectionNotFound {
|
|
t.Errorf("unknown chain: got err=%v, want ErrConnectionNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestClassifyCapabilityTier(t *testing.T) {
|
|
cases := []struct {
|
|
scopes string
|
|
want models.CapabilityTier
|
|
}{
|
|
{"", models.CapabilityTierUnknown},
|
|
{"pad:read", models.CapabilityTierReadOnly},
|
|
{"pad:read pad:write", models.CapabilityTierReadWrite},
|
|
{"pad:write", models.CapabilityTierReadWrite},
|
|
{"pad:read pad:write pad:admin", models.CapabilityTierFullAccess},
|
|
{"pad:admin", models.CapabilityTierFullAccess},
|
|
{"openid email", models.CapabilityTierUnknown},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := classifyCapabilityTier(tc.scopes); got != tc.want {
|
|
t.Errorf("classifyCapabilityTier(%q) = %q, want %q", tc.scopes, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestExtractAllowedWorkspacesFromSessionExtra covers the parse helper
|
|
// that drives the TASK-1522 backfill from session.Extra into the new
|
|
// oauth_connections + oauth_connection_workspaces tables. Replaces the
|
|
// retired parseAllowedWorkspacesFromSession test (which was the
|
|
// previous read-path's parse helper, removed when the read path
|
|
// switched to the new tables).
|
|
//
|
|
// Shape mapping (per IDEA-1517 §2 backfill spec):
|
|
//
|
|
// - missing / malformed / non-array → (hasKey=false, isStar=false, slugs=nil)
|
|
// → seeder writes all_current_workspaces=1, no join rows.
|
|
// - ["*"] singleton → (hasKey=true, isStar=true, slugs=nil)
|
|
// → seeder writes all_current_workspaces=1, no join rows.
|
|
// - explicit slug list → (hasKey=true, isStar=false, slugs=<list>)
|
|
// → seeder writes all_current_workspaces=0, one join row per slug.
|
|
func TestExtractAllowedWorkspacesFromSessionExtra(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
wantKey bool
|
|
wantStar bool
|
|
wantSlugs []string
|
|
}{
|
|
{"empty session", "", false, false, nil},
|
|
{"missing extra key", `{"extra":{}}`, false, false, nil},
|
|
{"explicit slug list", `{"extra":{"allowed_workspaces":["alpha","beta"]}}`, true, false, []string{"alpha", "beta"}},
|
|
{"wildcard singleton", `{"extra":{"allowed_workspaces":["*"]}}`, true, true, nil},
|
|
{"non-string elements dropped", `{"extra":{"allowed_workspaces":["alpha",42,"beta"]}}`, true, false, []string{"alpha", "beta"}},
|
|
{"explicit empty list", `{"extra":{"allowed_workspaces":[]}}`, true, false, nil},
|
|
{"wildcard mixed with slug", `{"extra":{"allowed_workspaces":["*","alpha"]}}`, true, false, []string{"*", "alpha"}},
|
|
{"malformed JSON", `not json`, false, false, nil},
|
|
{"non-array value", `{"extra":{"allowed_workspaces":"alpha"}}`, false, false, nil},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := extractAllowedWorkspacesFromSessionExtra(tc.in)
|
|
if got.hasKey != tc.wantKey {
|
|
t.Errorf("hasKey = %v, want %v", got.hasKey, tc.wantKey)
|
|
}
|
|
if got.isStar != tc.wantStar {
|
|
t.Errorf("isStar = %v, want %v", got.isStar, tc.wantStar)
|
|
}
|
|
if len(got.slugs) != len(tc.wantSlugs) {
|
|
t.Errorf("slugs len = %d (%v), want %d (%v)", len(got.slugs), got.slugs, len(tc.wantSlugs), tc.wantSlugs)
|
|
return
|
|
}
|
|
for i := range got.slugs {
|
|
if got.slugs[i] != tc.wantSlugs[i] {
|
|
t.Errorf("slugs[%d] = %q, want %q", i, got.slugs[i], tc.wantSlugs[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|