fix: clean migration, cross-team isolation test, and PUT field-set fix for target_lists

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-17 11:29:25 -05:00
parent 0c2d4ba685
commit adcdf39d35
5 changed files with 91 additions and 393 deletions

View File

@@ -0,0 +1,39 @@
"""add target_lists table
Revision ID: 5812e7df852f
Revises: 0f1ca2af3647
Create Date: 2026-02-17 11:20:42.919564
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '5812e7df852f'
down_revision: Union[str, None] = '0f1ca2af3647'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'target_lists',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('team_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('teams.id', ondelete='CASCADE'), nullable=False),
sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('targets', postgresql.JSONB(), nullable=False, server_default='[]'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
)
op.create_index('ix_target_lists_team_id', 'target_lists', ['team_id'])
def downgrade() -> None:
op.drop_index('ix_target_lists_team_id', table_name='target_lists')
op.drop_table('target_lists')

View File

@@ -1,388 +0,0 @@
"""add target_lists table
Revision ID: 853ebea730ab
Revises: 0f1ca2af3647
Create Date: 2026-02-17 11:12:14.598660
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '853ebea730ab'
down_revision: Union[str, None] = '0f1ca2af3647'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('target_lists',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('team_id', sa.UUID(), nullable=False),
sa.Column('created_by', sa.UUID(), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('targets', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_target_lists_team_id'), 'target_lists', ['team_id'], unique=False)
op.drop_index(op.f('ix_tree_shares_created_by'), table_name='tree_shares')
op.drop_index(op.f('ix_tree_shares_expires_at'), table_name='tree_shares')
op.drop_index(op.f('ix_tree_shares_share_token'), table_name='tree_shares')
op.drop_index(op.f('ix_tree_shares_tree_id'), table_name='tree_shares')
op.drop_table('tree_shares')
op.drop_table('_team_account_mapping')
op.drop_index(op.f('ix_account_feature_overrides_account_id'), table_name='account_feature_overrides')
op.drop_index(op.f('ix_account_invites_account_id'), table_name='account_invites')
op.drop_index(op.f('ix_account_invites_email'), table_name='account_invites')
op.drop_index(op.f('ix_account_limit_overrides_account_id'), table_name='account_limit_overrides')
op.alter_column('accounts', 'allow_public_shares',
existing_type=sa.BOOLEAN(),
comment='Policy: engineers can create public shares. Only affects NEW shares (grandfathered).',
existing_nullable=False,
existing_server_default=sa.text('true'))
op.alter_column('attachments', 'uploaded_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('audit_logs', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
op.drop_index(op.f('ix_plan_feature_defaults_plan'), table_name='plan_feature_defaults')
op.alter_column('refresh_tokens', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index(op.f('ix_session_ratings_account_created'), table_name='session_ratings')
op.drop_index(op.f('ix_session_ratings_tree_created'), table_name='session_ratings')
op.alter_column('session_share_views', 'session_id',
existing_type=sa.UUID(),
comment='Denormalized from share for analytics queries',
existing_nullable=False)
op.alter_column('session_share_views', 'viewer_id',
existing_type=sa.UUID(),
comment='NULL for public shares (unauthenticated views)',
existing_nullable=True)
op.alter_column('session_shares', 'account_id',
existing_type=sa.UUID(),
comment='Account that owns this share (denormalized from session at creation)',
existing_nullable=False)
op.alter_column('session_shares', 'share_token',
existing_type=sa.VARCHAR(length=64),
comment='URL-safe random token (48 bytes -> 64 base64 chars)',
existing_nullable=False)
op.alter_column('session_shares', 'share_name',
existing_type=sa.VARCHAR(length=100),
comment="Optional label: 'Training link', 'Customer escalation #1234'",
existing_nullable=True)
op.alter_column('session_shares', 'visibility',
existing_type=sa.VARCHAR(length=20),
comment='public = anyone with link, account = account members only',
existing_nullable=False,
existing_server_default=sa.text("'public'::character varying"))
op.alter_column('session_shares', 'expires_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
comment='Optional expiration for time-limited shares',
existing_nullable=True)
op.drop_constraint(op.f('session_shares_share_token_key'), 'session_shares', type_='unique')
op.drop_index(op.f('ix_session_shares_share_token'), table_name='session_shares')
op.create_index(op.f('ix_session_shares_share_token'), 'session_shares', ['share_token'], unique=True)
op.alter_column('sessions', 'started_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('sessions', 'completed_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
op.drop_index(op.f('ix_sessions_client_name'), table_name='sessions')
op.drop_index(op.f('ix_sessions_completed'), table_name='sessions')
op.drop_index(op.f('ix_sessions_ticket_number'), table_name='sessions')
op.drop_index(op.f('ix_sessions_tree_snapshot_gin'), table_name='sessions', postgresql_using='gin')
op.drop_index(op.f('ix_step_library_category_id'), table_name='step_library')
op.drop_index(op.f('ix_step_library_created_by'), table_name='step_library')
op.drop_index(op.f('ix_step_library_search'), table_name='step_library', postgresql_using='gin')
op.drop_index(op.f('ix_step_library_tags'), table_name='step_library', postgresql_using='gin')
op.drop_index(op.f('ix_step_library_team_id'), table_name='step_library')
op.drop_index(op.f('ix_step_library_visibility'), table_name='step_library', postgresql_where='(is_active = true)')
op.drop_index(op.f('ix_step_ratings_step_helpful'), table_name='step_ratings')
op.drop_index(op.f('ix_step_ratings_step_id'), table_name='step_ratings')
op.drop_index(op.f('ix_step_ratings_user_id'), table_name='step_ratings')
op.drop_constraint(op.f('uq_step_ratings_step_user'), 'step_ratings', type_='unique')
op.drop_index(op.f('ix_step_usage_log_step_id'), table_name='step_usage_log')
op.drop_index(op.f('ix_step_usage_log_user_step'), table_name='step_usage_log')
op.drop_index(op.f('ix_subscriptions_account_id'), table_name='subscriptions')
op.drop_index(op.f('ix_subscriptions_plan'), table_name='subscriptions')
op.alter_column('teams', 'created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('tree_categories', 'color',
existing_type=sa.VARCHAR(length=7),
comment='Hex color for category dot indicator',
existing_nullable=True,
existing_server_default=sa.text("'#3b82f6'::character varying"))
op.drop_index(op.f('ix_tree_tag_assignments_tag_id'), table_name='tree_tag_assignments')
op.drop_index(op.f('ix_tree_tag_assignments_tree_id'), table_name='tree_tag_assignments')
op.alter_column('trees', 'tree_type',
existing_type=sa.VARCHAR(length=20),
comment='Tree type: troubleshooting (branching decision tree) or procedural (linear step-by-step flow)',
existing_nullable=False,
existing_server_default=sa.text("'troubleshooting'::character varying"))
op.alter_column('trees', 'intake_form',
existing_type=postgresql.JSONB(astext_type=sa.Text()),
comment='Intake form field definitions for procedural flows (JSONB array)',
existing_nullable=True)
op.alter_column('trees', 'created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('trees', 'updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('trees', 'fork_reason',
existing_type=sa.VARCHAR(length=255),
comment="Brief reason: 'Added Cisco Meraki steps for our network'",
existing_nullable=True)
op.alter_column('trees', 'parent_updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
comment="Snapshot of parent's updated_at when fork created. Compare to detect parent updates.",
existing_nullable=True)
op.alter_column('trees', 'root_tree_id',
existing_type=sa.UUID(),
comment='Original tree at root of fork chain (NULL for non-forked trees)',
existing_nullable=True)
op.alter_column('trees', 'fork_depth',
existing_type=sa.INTEGER(),
comment='Fork depth: 0 = original, 1 = direct fork, 2 = fork of fork, etc.',
existing_nullable=False,
existing_server_default=sa.text('0'))
op.drop_index(op.f('idx_trees_fts'), table_name='trees', postgresql_using='gin')
op.drop_index(op.f('ix_trees_fork_analytics'), table_name='trees')
op.drop_index(op.f('ix_user_folder_trees_folder_id'), table_name='user_folder_trees')
op.drop_index(op.f('ix_user_folder_trees_tree_id'), table_name='user_folder_trees')
op.alter_column('user_pinned_trees', 'pinned_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index(op.f('idx_user_pinned_trees_tree'), table_name='user_pinned_trees')
op.drop_index(op.f('idx_user_pinned_trees_user'), table_name='user_pinned_trees')
op.create_index(op.f('ix_user_pinned_trees_tree_id'), 'user_pinned_trees', ['tree_id'], unique=False)
op.create_index(op.f('ix_user_pinned_trees_user_id'), 'user_pinned_trees', ['user_id'], unique=False)
op.alter_column('users', 'account_id',
existing_type=sa.UUID(),
nullable=True)
op.drop_constraint(op.f('fk_users_account_id'), 'users', type_='foreignkey')
op.create_foreign_key(None, 'users', 'accounts', ['account_id'], ['id'], ondelete='RESTRICT')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'users', type_='foreignkey')
op.create_foreign_key(op.f('fk_users_account_id'), 'users', 'accounts', ['account_id'], ['id'], ondelete='CASCADE')
op.alter_column('users', 'account_id',
existing_type=sa.UUID(),
nullable=False)
op.drop_index(op.f('ix_user_pinned_trees_user_id'), table_name='user_pinned_trees')
op.drop_index(op.f('ix_user_pinned_trees_tree_id'), table_name='user_pinned_trees')
op.create_index(op.f('idx_user_pinned_trees_user'), 'user_pinned_trees', ['user_id'], unique=False)
op.create_index(op.f('idx_user_pinned_trees_tree'), 'user_pinned_trees', ['tree_id'], unique=False)
op.alter_column('user_pinned_trees', 'pinned_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=True,
existing_server_default=sa.text('now()'))
op.create_index(op.f('ix_user_folder_trees_tree_id'), 'user_folder_trees', ['tree_id'], unique=False)
op.create_index(op.f('ix_user_folder_trees_folder_id'), 'user_folder_trees', ['folder_id'], unique=False)
op.create_index(op.f('ix_trees_fork_analytics'), 'trees', ['root_tree_id', 'fork_depth'], unique=False)
op.create_index(op.f('idx_trees_fts'), 'trees', [sa.literal_column("to_tsvector('english'::regconfig, (COALESCE(name, ''::character varying)::text || ' '::text) || COALESCE(description, ''::text))")], unique=False, postgresql_using='gin')
op.alter_column('trees', 'fork_depth',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Fork depth: 0 = original, 1 = direct fork, 2 = fork of fork, etc.',
existing_nullable=False,
existing_server_default=sa.text('0'))
op.alter_column('trees', 'root_tree_id',
existing_type=sa.UUID(),
comment=None,
existing_comment='Original tree at root of fork chain (NULL for non-forked trees)',
existing_nullable=True)
op.alter_column('trees', 'parent_updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
comment=None,
existing_comment="Snapshot of parent's updated_at when fork created. Compare to detect parent updates.",
existing_nullable=True)
op.alter_column('trees', 'fork_reason',
existing_type=sa.VARCHAR(length=255),
comment=None,
existing_comment="Brief reason: 'Added Cisco Meraki steps for our network'",
existing_nullable=True)
op.alter_column('trees', 'updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('trees', 'created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('trees', 'intake_form',
existing_type=postgresql.JSONB(astext_type=sa.Text()),
comment=None,
existing_comment='Intake form field definitions for procedural flows (JSONB array)',
existing_nullable=True)
op.alter_column('trees', 'tree_type',
existing_type=sa.VARCHAR(length=20),
comment=None,
existing_comment='Tree type: troubleshooting (branching decision tree) or procedural (linear step-by-step flow)',
existing_nullable=False,
existing_server_default=sa.text("'troubleshooting'::character varying"))
op.create_index(op.f('ix_tree_tag_assignments_tree_id'), 'tree_tag_assignments', ['tree_id'], unique=False)
op.create_index(op.f('ix_tree_tag_assignments_tag_id'), 'tree_tag_assignments', ['tag_id'], unique=False)
op.alter_column('tree_categories', 'color',
existing_type=sa.VARCHAR(length=7),
comment=None,
existing_comment='Hex color for category dot indicator',
existing_nullable=True,
existing_server_default=sa.text("'#3b82f6'::character varying"))
op.alter_column('teams', 'created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.create_index(op.f('ix_subscriptions_plan'), 'subscriptions', ['plan'], unique=False)
op.create_index(op.f('ix_subscriptions_account_id'), 'subscriptions', ['account_id'], unique=False)
op.create_index(op.f('ix_step_usage_log_user_step'), 'step_usage_log', ['user_id', 'step_id'], unique=False)
op.create_index(op.f('ix_step_usage_log_step_id'), 'step_usage_log', ['step_id'], unique=False)
op.create_unique_constraint(op.f('uq_step_ratings_step_user'), 'step_ratings', ['step_id', 'user_id'], postgresql_nulls_not_distinct=False)
op.create_index(op.f('ix_step_ratings_user_id'), 'step_ratings', ['user_id'], unique=False)
op.create_index(op.f('ix_step_ratings_step_id'), 'step_ratings', ['step_id'], unique=False)
op.create_index(op.f('ix_step_ratings_step_helpful'), 'step_ratings', ['step_id', 'was_helpful'], unique=False)
op.create_index(op.f('ix_step_library_visibility'), 'step_library', ['visibility'], unique=False, postgresql_where='(is_active = true)')
op.create_index(op.f('ix_step_library_team_id'), 'step_library', ['team_id'], unique=False)
op.create_index(op.f('ix_step_library_tags'), 'step_library', ['tags'], unique=False, postgresql_using='gin')
op.create_index(op.f('ix_step_library_search'), 'step_library', [sa.literal_column("to_tsvector('english'::regconfig, title::text)")], unique=False, postgresql_using='gin')
op.create_index(op.f('ix_step_library_created_by'), 'step_library', ['created_by'], unique=False)
op.create_index(op.f('ix_step_library_category_id'), 'step_library', ['category_id'], unique=False)
op.create_index(op.f('ix_sessions_tree_snapshot_gin'), 'sessions', ['tree_snapshot'], unique=False, postgresql_using='gin')
op.create_index(op.f('ix_sessions_ticket_number'), 'sessions', ['ticket_number'], unique=False)
op.create_index(op.f('ix_sessions_completed'), 'sessions', ['completed_at'], unique=False)
op.create_index(op.f('ix_sessions_client_name'), 'sessions', ['client_name'], unique=False)
op.alter_column('sessions', 'completed_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
op.alter_column('sessions', 'started_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index(op.f('ix_session_shares_share_token'), table_name='session_shares')
op.create_index(op.f('ix_session_shares_share_token'), 'session_shares', ['share_token'], unique=False)
op.create_unique_constraint(op.f('session_shares_share_token_key'), 'session_shares', ['share_token'], postgresql_nulls_not_distinct=False)
op.alter_column('session_shares', 'expires_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
comment=None,
existing_comment='Optional expiration for time-limited shares',
existing_nullable=True)
op.alter_column('session_shares', 'visibility',
existing_type=sa.VARCHAR(length=20),
comment=None,
existing_comment='public = anyone with link, account = account members only',
existing_nullable=False,
existing_server_default=sa.text("'public'::character varying"))
op.alter_column('session_shares', 'share_name',
existing_type=sa.VARCHAR(length=100),
comment=None,
existing_comment="Optional label: 'Training link', 'Customer escalation #1234'",
existing_nullable=True)
op.alter_column('session_shares', 'share_token',
existing_type=sa.VARCHAR(length=64),
comment=None,
existing_comment='URL-safe random token (48 bytes -> 64 base64 chars)',
existing_nullable=False)
op.alter_column('session_shares', 'account_id',
existing_type=sa.UUID(),
comment=None,
existing_comment='Account that owns this share (denormalized from session at creation)',
existing_nullable=False)
op.alter_column('session_share_views', 'viewer_id',
existing_type=sa.UUID(),
comment=None,
existing_comment='NULL for public shares (unauthenticated views)',
existing_nullable=True)
op.alter_column('session_share_views', 'session_id',
existing_type=sa.UUID(),
comment=None,
existing_comment='Denormalized from share for analytics queries',
existing_nullable=False)
op.create_index(op.f('ix_session_ratings_tree_created'), 'session_ratings', ['tree_id', 'created_at'], unique=False)
op.create_index(op.f('ix_session_ratings_account_created'), 'session_ratings', ['account_id', 'created_at'], unique=False)
op.alter_column('refresh_tokens', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=True,
existing_server_default=sa.text('now()'))
op.create_index(op.f('ix_plan_feature_defaults_plan'), 'plan_feature_defaults', ['plan'], unique=False)
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
op.alter_column('audit_logs', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=True,
existing_server_default=sa.text('now()'))
op.alter_column('attachments', 'uploaded_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('accounts', 'allow_public_shares',
existing_type=sa.BOOLEAN(),
comment=None,
existing_comment='Policy: engineers can create public shares. Only affects NEW shares (grandfathered).',
existing_nullable=False,
existing_server_default=sa.text('true'))
op.create_index(op.f('ix_account_limit_overrides_account_id'), 'account_limit_overrides', ['account_id'], unique=False)
op.create_index(op.f('ix_account_invites_email'), 'account_invites', ['email'], unique=False)
op.create_index(op.f('ix_account_invites_account_id'), 'account_invites', ['account_id'], unique=False)
op.create_index(op.f('ix_account_feature_overrides_account_id'), 'account_feature_overrides', ['account_id'], unique=False)
op.create_table('_team_account_mapping',
sa.Column('team_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('account_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('owner_user_id', sa.UUID(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('team_id', name=op.f('_team_account_mapping_pkey'))
)
op.create_table('tree_shares',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('tree_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('share_token', sa.VARCHAR(length=64), autoincrement=False, nullable=False),
sa.Column('created_by', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('allow_forking', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.Column('expires_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], name=op.f('tree_shares_created_by_fkey'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('tree_shares_tree_id_fkey'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('tree_shares_pkey')),
sa.UniqueConstraint('share_token', name=op.f('tree_shares_share_token_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
op.create_index(op.f('ix_tree_shares_tree_id'), 'tree_shares', ['tree_id'], unique=False)
op.create_index(op.f('ix_tree_shares_share_token'), 'tree_shares', ['share_token'], unique=False)
op.create_index(op.f('ix_tree_shares_expires_at'), 'tree_shares', ['expires_at'], unique=False)
op.create_index(op.f('ix_tree_shares_created_by'), 'tree_shares', ['created_by'], unique=False)
op.drop_index(op.f('ix_target_lists_team_id'), table_name='target_lists')
op.drop_table('target_lists')
# ### end Alembic commands ###

View File

@@ -85,11 +85,12 @@ async def update_target_list(
target_list = result.scalar_one_or_none()
if not target_list:
raise HTTPException(status_code=404, detail="Target list not found")
if data.name is not None:
update_fields = data.model_fields_set
if "name" in update_fields and data.name is not None:
target_list.name = data.name
if data.description is not None:
target_list.description = data.description
if data.targets is not None:
if "description" in update_fields:
target_list.description = data.description # allow setting to None
if "targets" in update_fields and data.targets is not None:
target_list.targets = [t.model_dump() for t in data.targets]
await db.commit()
await db.refresh(target_list)

View File

@@ -18,7 +18,7 @@ class TargetListCreate(BaseModel):
class TargetListUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
targets: Optional[list[TargetEntry]] = None
targets: Optional[list[TargetEntry]] = Field(None, min_length=1)
class TargetListResponse(BaseModel):

View File

@@ -105,3 +105,49 @@ async def test_delete_target_list(client: AsyncClient, auth_headers: dict):
get = await client.get(f"/api/v1/target-lists/{list_id}", headers=auth_headers)
assert get.status_code == 404
@pytest.mark.asyncio
async def test_cannot_access_other_teams_list(client: AsyncClient, auth_headers: dict, test_db):
"""User from team B cannot access team A's list."""
import uuid
from app.models.team import Team
from app.models.user import User
from app.core.security import get_password_hash
# Create team A list using existing auth_headers
create = await client.post(
"/api/v1/target-lists/",
json={"name": "Team A List", "targets": [{"label": "SRV-A"}]},
headers=auth_headers,
)
assert create.status_code == 201
list_id = create.json()["id"]
# Create a separate team B with its own user
team_b = Team(name=f"Team B {uuid.uuid4()}")
test_db.add(team_b)
await test_db.flush()
user_b = User(
email=f"userb_{uuid.uuid4()}@test.com",
password_hash=get_password_hash("password123"),
name="User B",
is_active=True,
team_id=team_b.id,
role="engineer",
)
test_db.add(user_b)
await test_db.flush()
# Get auth token for user B
login = await client.post(
"/api/v1/auth/login/json",
json={"email": user_b.email, "password": "password123"},
)
assert login.status_code == 200
token_b = login.json()["access_token"]
headers_b = {"Authorization": f"Bearer {token_b}"}
# Team B cannot access Team A's list
resp = await client.get(f"/api/v1/target-lists/{list_id}", headers=headers_b)
assert resp.status_code == 404